Compare commits

...

14 commits

Author SHA1 Message Date
Sheldon Cooper
da7d75641d feat(ktb): add new-lemma feature, fix some styling. 2026-07-23 01:48:55 -04:00
Sheldon Cooper
9cd6667e4a fix(ktb): fix alignment on kofal table 2026-07-21 14:57:44 -04:00
Sheldon Cooper
c1afb0aa2c feat(ktb): add delete example and definition buttons; add pagination 2026-07-21 00:02:54 -04:00
nikomiko
6873a8d920 feat(ktb): add markdown parsing, sanitation, and add-lect feature 2026-07-20 15:49:59 -04:00
Sheldon Cooper
bf8538bbb9 plus: antaa plus presmi 2026-07-20 01:52:57 -04:00
Sheldon Cooper
17f6216e67 (wip) - stuff 2026-07-16 17:43:18 -04:00
nikomiko
d01128d54f feat: remove old site UI, replace with ktb 2026-07-14 19:31:18 -04:00
Benjamin Singleton
93b74d0179 Merge remote-tracking branch 'origin/main' into more-pages
Some checks failed
SSH Deploy to staging / remote-update (push) Has been cancelled
2026-03-23 17:28:17 -05:00
Benjamin Singleton
4295a83ee1 feat(i18n): first draft of vi18n lib 2026-03-23 16:52:13 -05:00
Benjamin Singleton
f7a504d5a3 wip: updated markdown parsing for starred spans, improved error handling behavior of i18n messages 2026-03-02 00:16:07 -06:00
nikomiko
262b8c578f fix: fixed vp_VL.ts 2026-03-01 23:34:14 -05:00
TheDragonOfFlame
382f3dd0a2
feat: add more greetings and minemiaha language (#49)
* Add minemiaha (or razhanu?)

* Minemiaha to en_US

* Update greeting.ts

* Update vilantic.ts

* Add files via upload

* Update greeting.ts

* Added -miko to greeting.ts
2026-03-01 22:32:03 -05:00
Nginearing
58e04edaba improvement: string parity
- create parity between localisations for the Discord description string
2026-02-14 00:11:42 -05:00
Nginearing
e11f0a6b8b improvement: viossa locale
Complete localisation into Viossa
2026-02-14 00:11:42 -05:00
74 changed files with 1722 additions and 30700 deletions

View file

@ -1,28 +1,20 @@
# Viossa.net
# Viossa.net kotoba-tumam
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
### Backend
- [Node.js](https://nodejs.org/)
- [TypeScript](https://www.typescriptlang.org/)
- [pnpm](https://pnpm.io/)
- [Turborepo](https://turborepo.com/)
### Frontend
- [Vue 3](https://vuejs.org/)
- [Vite](https://vite.dev/)
- The ktb frontend is purely coded in HTML, CSS, and browser JS.
- [AlpineJS](https://alpinejs.dev/)
Additionally, we will be following [**atomic design principles**](https://bradfrost.com/blog/post/atomic-web-design/) to organize the components of the project.
### Styling
- [Bulma](https://bulma.io/)
- [Tailwind CSS](https://tailwindcss.com/)
- [Sass](https://sass-lang.com/)
### Backend
- [Node.js](https://nodejs.org/)
### Linting
- [Prettier](https://prettier.io/)
@ -46,15 +38,12 @@ Additionally, we will be following [**atomic design principles**](https://bradfr
This project uses Turborepo for task management/caching. Install Turborepo globally on your machine to allow for executing turbo commands more easily: `pnpm i -g turbo` (this is needed to continue with the instructions below)
### Frontend (Viossa.net)
1. Ensure you're in the root directory of the project (`ViossaDotNet`)
1. Move into the app's directory: `cd apps/vdn-static`
1. To run the site, use `turbo dev`. This will set up watchers to build all libraries used by the frontend, as well as hot-refreshing the site as changes are made to it.
1. To view the website running locally, visit http://localhost:1224/ in your browser!
1. The frontend no longer requires a compilation step. Yippee!
### Backend (Viossa DB)
1. Ensure you're in the root directory of the project (`ViossaDotNet`)
1. Move into the app's directory: `cd apps/vdb-backend`
1. To run the site, use `turbo start`. This will build all of the app's dependencies and then start the application.
1. To run the API, use `turbo start`. This will build all of the app's dependencies and then start the application.
1. **NOTE:** Backend apps are not watched/hot-refreshed like frontend apps! If you make changes, you must kill the app and re-run it to apply changes.
1. To view a sample response from the backend API, visit http://localhost:1225/sample in your browser!

254
apps/ktb-static/script.js Normal file
View file

@ -0,0 +1,254 @@
let HOST = 'http://localhost:1225'
document.addEventListener("alpine:init", () => {
Alpine.store("dictionary", {
search_results: {
terms: 1,
results: [
{
lemma_name: "wiigel",
word_forms: [
{
word_form_id: 1262,
word_form: "wijgeu",
lect: { name: "ArekaDareka" },
},
{
word_form_id: 1263,
word_form: "wigl",
lect: { name: "Anott" },
},
{
word_form_id: 1264,
word_form: "wijev",
lect: { name: "Djin" },
},
],
},
],
},
prefs: {
colorSidebarBg: "#000",
colorMainBg: "#eee",
colorCardBg: "#fff",
colorPrimary: "#0bf",
colorSecondary: "#08e",
colorText: "#333",
colorTextSecondary: "#555",
},
update_css_variables() {
document.documentElement.style.setProperty(
"--color-sidebar-bg",
this.prefs.colorSidebarBg,
);
document.documentElement.style.setProperty(
"--color-main-bg",
this.prefs.colorMainBg,
);
document.documentElement.style.setProperty(
"--color-card-bg",
this.prefs.colorCardBg,
);
document.documentElement.style.setProperty(
"--color-primary",
this.prefs.colorPrimary,
);
document.documentElement.style.setProperty(
"--color-secondary",
this.prefs.colorSecondary,
);
document.documentElement.style.setProperty(
"--color-text",
this.prefs.colorText,
);
document.documentElement.style.setProperty(
"--color-text-secondary",
this.prefs.colorTextSecondary,
);
},
load_preferences() {
const saved_prefs = localStorage.getItem("prefs");
if (saved_prefs) {
this.prefs = { ...this.prefs, ...JSON.parse(saved_prefs) };
this.update_css_variables();
}
},
save_preferences() {
localStorage.setItem("prefs", JSON.stringify(this.prefs));
this.update_css_variables();
},
async fetch_all_lects() {
try {
const res = await axios.get(`${HOST}/lects`);
return res.data;
} catch (e) {
console.error(e);
}
},
async post_lect(lect_name) {
try {
const res = await axios.post(`${HOST}/lect`, {
lect_name,
});
return res.data.lect;
} catch (e) {
console.error(e);
}
},
async fetch_all_terms(search_term) {
try {
const res = await axios.get(`${HOST}/search`, {
params: { search_term: search_term },
});
this.search_results = res.data;
} catch (e) {
console.error(e);
}
},
async fetch_one_lemma_detail(lemma_name) {
try {
const res = await axios.get(
`${HOST}/lemma-detail`,
{ params: { lemma_name } },
);
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async put_definition(definition_id, definition_text, lemma_name) {
try {
const res = await axios.put(
`${HOST}/definition`,
{ definition_id, definition_text, lemma_name },
);
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async delete_definition(definition_id, definition_text) {
if (
window.confirm(`Du keshite imi ${definition_id}.\nPravda?`)
) {
try {
const res = await axios.delete(
`${HOST}/definition/${definition_id}`,
);
} catch (e) {
console.error(e);
}
}
},
async put_example(example_id, example_text, lemma_name) {
try {
const res = await axios.put(`${HOST}/example`, {
example_id,
example_text,
lemma_name,
});
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async delete_example(example_id, example_text) {
if (
window.confirm(`Du keshite tato ${example_id}.\nPravda?`)
) {
try {
const res = await axios.delete(
`${HOST}/example/${example_id}`,
);
} catch (e) {
console.error(e);
}
}
},
async put_word_form_text(word_form_id, word_form_text) {
try {
const res = await axios.put(`${HOST}/word-form`, {
word_form_id,
word_form_text,
});
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async post_new_word_form(lemma_name, lect_name, word_form_text) {
if(!lemma_name || !lect_name || !word_form_text) {
console.error(`Missing parameter:\n${JSON.stringify({
lemma_name, lect_name, word_form_text
})}`);
}
try {
const res = await axios.post(
`${HOST}/word-form`,
{ lemma_name, lect_name, word_form_text },
);
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async post_lemma(lect_name, word_form_text){
/* Accepts a new word form for a lemma and the corresponding lect, initializing with the provided form. */
try {
const res = await axios.post(
`${HOST}/lemma`,
{ lect_name, word_form_text },
);
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
},
async delete_word_form(word_form_id, word_form_text) {
if (
window.confirm(`Du keshite kofal ${word_form_text}.\nPravda?`)
) {
try {
const res = await axios.delete(
`${HOST}/word-form/${word_form_id}`,
);
return res.data.lemma_detail;
} catch (e) {
console.error(e);
}
}
},
filter_results(search_term) {
if (!search_term) return this.search_results.results;
return this.search_results.results.filter((item) =>
item.word_forms
.map((wf) => wf.word_form.toLowerCase())
.join()
.includes(search_term.toLowerCase()),
);
},
md(text){
console.debug(marked.parse(text));
return DOMPurify.sanitize(marked.parse(text), {ALLOWED_TAGS: ['br', 'em', 'strong', 'code', '#text']});
},
});
});

637
apps/ktb-static/search.html Normal file
View file

@ -0,0 +1,637 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dictionary Cards</title>
<link rel="stylesheet" href="styles.css" />
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@18.0.6/lib/marked.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dompurify@3.4.12/dist/purify.min.js"></script>
<script src="https://unpkg.com/alpinejs" defer></script>
<script src="script.js"></script>
</head>
<body>
<div
class="app-container"
@alpine:init="$store.dictionary.load_preferences()"
x-data="{
search_term: '',
showPrefs: false,
lects: [],
current_result_page: 0,
results_per_page: 30,
new_lemma_form_open: false,
md(text){
return $store.dictionary.md(text);
},
async put_word_form_text(word_form_id, word_form_text){
$store.dictionary.put_word_form_text(word_form_id, word_form_text).then(()=>{
this.load_detail();
});
},
async post_new_word_form(lemma_name, lect_name, word_form_text){
$store.dictionary.post_new_word_form(lemma_name, lect_name, word_form_text).then(()=>{
this.load_detail();
});
},
async delete_word_form(word_form_id, word_form_text){
$store.dictionary.delete_word_form(word_form_id, word_form_text).then(()=>{
this.load_detail();
});
},
async put_definition(definition_id, definition_text){
$store.dictionary.put_definition(definition_id, definition_text, this.lemma_detail?.lemma_name).then(()=>{
this.load_detail();
});
},
async delete_definition(definition_id, definition_text){
$store.dictionary.delete_definition(definition_id, definition_text).then(()=>{
this.load_detail();
});
},
async put_example(example_id, example_text){
$store.dictionary.put_example(example_id, example_text, this.lemma_detail?.lemma_name).then(()=>{
this.load_detail();
});
},
async post_lemma(lect_name, word_form_text){
$store.dictionary.post_lemma(lect_name, word_form_text).then(lemma=>console.info(JSON.stringify(lemma)));
},
async delete_example(example_id, example_text){
$store.dictionary.delete_example(example_id, example_text).then(()=>{
this.load_detail();
});
},
}"
x-init="$store.dictionary.fetch_all_lects().then((lects_response)=>{
if(!!lects_response.lects) {
lects = lects_response.lects;
}
});">
<aside class="sidebar">
<div class="preferences-pane">
<button
@click="showPrefs = !showPrefs"
class="prefs-toggle is-fullwidth">
Sejenazma
</button>
<div class="prefs-content" x-show="showPrefs">
<div class="prefs-section">
<h3>Sejenazma</h3>
<label>
Hinafarge fu ljevatel:
<input
type="color"
x-model="$store.dictionary.prefs.colorSidebarBg"
@input="$store.dictionary.save_preferences()" />
</label>
<label>
Hinafarge fu glavna lehtia:
<input
type="color"
x-model="$store.dictionary.prefs.colorMainBg"
@input="$store.dictionary.save_preferences()" />
</label>
<label>
Hinafarge fu kofuga:
<input
type="color"
x-model="$store.dictionary.prefs.colorCardBg"
@input="$store.dictionary.save_preferences()" />
</label>
<label>
Viktifarge:
<input
type="color"
x-model="$store.dictionary.prefs.colorPrimary"
@input="$store.dictionary.save_preferences()" />
</label>
<label>
Nisvikti farge:
<input
type="color"
x-model="$store.dictionary.prefs.colorSecondary"
@input="$store.dictionary.save_preferences()" />
</label>
</div>
</div>
</div>
</aside>
<main class="main-content">
<div class="top-bar sticky top">
<div class="search-bar">
<button
@click="$store.dictionary.fetch_all_terms(search_term)">
Zuha
</button>
<input
type="text"
x-model="search_term"
@keydown.enter="$store.dictionary.fetch_all_terms(search_term)"
placeholder="Tasta ko(tel) her..." />
<button
class="hollow primary"
@click="new_lemma_form_open = true">
+
</button>
</div>
<div
class="flex-row"
x-data="{
page_count: 1,
item_count: 0
}"
x-effect="current_result_page = Math.min(current_result_page, page_count-1); page_count = Math.ceil($store.dictionary.search_results.terms/results_per_page); item_count = $store.dictionary.search_results.terms;">
<button
class="hollow primary"
:disabled="!(item_count > results_per_page && current_result_page > 0)"
@click="current_result_page-=1">
<strong></strong>
</button>
<div
x-text="`${current_result_page + 1} / ${page_count}`"></div>
<button
class="hollow primary"
:disabled="!(item_count > results_per_page && current_result_page+1 < page_count)"
@click="current_result_page+=1">
<strong></strong>
</button>
<select x-model="results_per_page">
<option value="15">15</option>
<option value="30">30</option>
<option value="60">60</option>
<option value="120">120</option>
</select>
</div>
</div>
<div class="cards-container">
<template
x-for="item in $store.dictionary.filter_results(search_term).slice(current_result_page*results_per_page, Math.min((current_result_page+1)*results_per_page, $store.dictionary.search_results.terms))"
:key="item.lemma_name">
<div
class="card"
:class="expanded?'is-expanded':''"
@click.self="toggle_expand()"
x-data="{
expanded: false,
adding_definition: false,
adding_example: false,
adding_media: false,
example_text: '',
definition_text: '',
lemma_detail: null,
async toggle_expand() {
if(!this.lemma_detail){
/* If there isn't a lemma detail loaded yet, get it then expand. */
await this.load_detail();
if(this.lemma_detail) {
this.expanded = true;
} else {
alert(`Failed to load lemma ${item.lemma_name}`);
}
return;
}
/* If there is content, simply toggle the state of the card */
this.expanded = !this.expanded;
},
async load_detail(){
this.lemma_detail = await $store.dictionary.fetch_one_lemma_detail(item.lemma_name);
}
}">
<h3 x-text="item.lemma_name"></h3>
<p class="columned">
<template x-for="wf in item.word_forms">
<span
x-text="wf.word_form + ' '"
:title="wf.lect.name">
</span>
</template>
</p>
<button
class="is-fullwidth dropdown"
@click.self="toggle_expand()">
<span x-show="!expanded">v</span>
<span x-show="expanded">^</span>
</button>
<div x-show="expanded && lemma_detail">
<!-- wfs -->
<h4>Trofal</h4>
<table class="is-fullwidth">
<tbody>
<template
x-for="wf2 in (lemma_detail?.word_forms ?? [])">
<tr
x-data="{
editing:false,
edited_text: wf2.word_form
}">
<td
style="width: 8em"
x-text="wf2 ? wf2.lect.name : '(empty)'"></td>
<td>
<span
x-text="wf2 ? wf2.word_form : '(empty)'"
@click="editing = true"
x-show="!editing"></span>
<input
type="text"
class="is-fullwidth"
@click.outside="editing = false"
x-show="editing"
x-model="edited_text" />
</td>
<td style="width: 5em">
<div class="float-right">
<button
class="tiny hollow primary"
x-show="!editing"
title="kawari"
@click="editing = true">
~
</button>
<button
class="tiny hollow delete"
x-show="!editing"
title="keshite"
@click="await delete_word_form(wf2.word_form_id, wf2.word_form)">
×
</button>
<button
class="tiny edit"
x-show="editing"
title="jame"
@click="editing = false">
/
</button>
<button
class="tiny"
x-show="editing"
title="antaa"
@click="await put_word_form_text(wf2.word_form_id, edited_text); editing = false">
>
</button>
</div>
</td>
</tr>
</template>
</tbody>
<!-- Add new wf -->
<tfoot>
<tr
x-data="{
adding_word_form:false,
new_word_form_text:'',
selected_lect:'',
adding_lect:false,
new_lect_name:''
}">
<td
colspan="3"
x-show="!adding_word_form">
<button
class="is-fullwidth hollow primary"
@click="adding_word_form = !adding_word_form">
+
</button>
</td>
<td
colspan="3"
x-show="adding_word_form"
style="width: 12em">
<div
style="
display: flex;
flex-direction: row;
gap: 2em;
justify-content: space-between;
">
<div>
<select
name="vilgovor"
x-model="selected_lect"
x-show="!adding_lect">
<option
value=""
selected
disabled
hidden>
Sentaku...
</option>
<template
x-for="lect in lects">
<option
:value="lect.name"
x-text="lect.name"></option>
</template>
</select>
<input
type="text"
x-show="adding_lect"
x-model="new_lect_name" />
<button
class="float-right tiny primary"
x-show="!adding_lect"
@click="adding_lect = true"
title="neo govor">
+
</button>
<button
class="float-right tiny edit"
x-show="adding_lect"
title="jame"
@click="adding_lect = false">
/
</button>
</div>
<div>
<input
type="text"
x-model="new_word_form_text" />
</div>
<div class="float-right">
<button
class="tiny edit"
x-show="adding_word_form"
title="jame"
@click="adding_word_form = false">
/
</button>
<button
class="tiny"
x-show="adding_word_form"
title="antaa"
:disabled="!(selected_lect || new_lect_name)"
@click="await post_new_word_form(item.lemma_name, adding_lect ? new_lect_name : selected_lect, new_word_form_text); adding_word_form = false">
>
</button>
</div>
</div>
</td>
</tr>
</tfoot>
</table>
<h4>Imi</h4>
<ul>
<template
x-for="def in (lemma_detail?.definitions ?? [])">
<li
x-data="{
editing: false,
edited_text: def.definition_text.toString()
}"
@click.outside="editing = false">
<span
x-show="!editing"
x-html="md(def.definition_text)"
@click="editing = true"></span>
<input
type="text"
x-show="editing"
x-model="edited_text" />
<div class="float-right">
<button
class="tiny hollow primary"
title="kawari"
x-show="!editing"
@click="editing = true">
~
</button>
<button
class="tiny edit"
title="jame"
x-show="editing"
@click="editing = false">
/
</button>
<button
class="tiny primary"
title="antaa"
x-show="editing"
@click="await put_definition(def.definition_id, edited_text); editing = false">
>
</button>
<button
class="tiny hollow delete"
x-show="!editing"
title="keshite"
@click="await delete_definition(def.definition_id, def.definition_text)">
×
</button>
</div>
</li>
</template>
</ul>
<textarea
class="is-fullwidth"
x-show="adding_definition"
placeholder="Tasta imi..."
x-model="definition_text"></textarea>
<div class="flex-row">
<button
class="edit"
x-show="adding_definition"
@click="adding_definition = false">
/
</button>
<button
x-show="adding_definition"
type="button"
@click="put_definition(null, definition_text)
.then(()=>{
adding_definition = false;
definition_text = '';
})">
Popoczta
</button>
</div>
<button
class="is-fullwidth hollow primary"
x-show="!adding_definition"
@click="adding_definition = !adding_definition">
+
</button>
<h4>Tatoeba</h4>
<ul>
<template
x-for="ex in (lemma_detail?.examples ?? [])">
<li
x-data="{
editing:false,
edited_text: ex.example_text.toString()
}"
@click.outside="editing = false">
<span
x-show="!editing"
x-html="md(ex.example_text)"
@click="editing = true"></span>
<input
type="text"
x-show="editing"
x-model="edited_text" />
<div class="float-right">
<button
class="tiny hollow primary"
x-show="!editing"
@click="editing = true"
title="kawari">
~
</button>
<button
class="tiny edit"
x-show="editing"
@click="editing = false"
title="jame">
/
</button>
<button
class="tiny"
x-show="editing"
type="button"
@click="await put_example(ex.example_id, edited_text); editing = false"
title="antaa">
>
</button>
<button
class="tiny hollow delete"
x-show="!editing"
title="keshite"
@click="await delete_example(ex.example_id, ex.example_text)">
×
</button>
</div>
</li>
</template>
</ul>
<textarea
class="is-fullwidth"
x-show="adding_example"
placeholder="Tasta tatoeba..."
x-model="example_text"></textarea>
<button
class="edit"
x-show="adding_example"
@click="adding_example = false">
/
</button>
<button
x-show="adding_example"
type="button"
@click="put_example(null, example_text)
.then(()=>{
adding_example = false;
example_text = '';
})">
Popoczta
</button>
<button
class="is-fullwidth hollow primary"
x-show="!adding_example"
@click="adding_example = !adding_example">
+
</button>
</div>
</div>
</template>
</div>
</main>
<div
x-show="new_lemma_form_open"
class="modal"
x-data="{
adding_word_form:false,
adding_lect:false,
new_word_form_text:'',
selected_lect:'',
new_lect_name:''
}"
@click.self="new_lemma_form_open = false;">
<div class="card">
<h3>Neo kotoba...</h3>
<div class="flex-row">
<div>
<label>
<div>Viljena govor</div>
<div>
<select
name="vilgovor"
x-model="selected_lect"
x-show="!adding_lect">
<option
value=""
selected
disabled
hidden>
Sentaku...
</option>
<template x-for="lect in lects">
<option
:value="lect.name"
x-text="lect.name"></option>
</template>
</select>
<input
type="text"
x-show="adding_lect"
x-model="new_lect_name" />
<button
class="float-right tiny primary"
x-show="!adding_lect"
@click="adding_lect = true"
title="neo govor">
+
</button>
<button
class="float-right tiny edit"
x-show="adding_lect"
title="jame"
@click="adding_lect = false">
/
</button>
</div>
</label>
</div>
<label>
<div>Kakufal</div>
<div>
<input
type="text"
x-model="new_word_form_text" />
</div>
</label>
</div>
<div style="margin-top: var(--space-sm)">
<button
class="edit"
@click="new_lemma_form_open = false">
/
</button>
<button
type="button"
@click="post_lemma(new_lect_name?new_lect_name:selected_lect, new_word_form_text)
.then(()=>{
new_lemma_form_open = false;
})">
Popoczta
</button>
</div>
</div>
</div>
</div>
</body>
</html>

386
apps/ktb-static/styles.css Normal file
View file

@ -0,0 +1,386 @@
:root {
--color-sidebar-bg: #000;
--color-primary: #0bf;
--color-secondary: #08e;
--color-edit: #fb0;
--color-delete: #b10;
--color-main-bg: #eee;
--color-card-bg: #fff;
--color-text: #333;
--color-text-secondary: #555;
--color-shadow: rgba(0, 0, 0, 0.1);
--color-border: #ddd;
--space-xs: 0.2rem;
--space-sm: 0.5rem;
--space-md: 1.0rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 1px 2px var(--color-shadow);
--shadow-md: 0 2px 4px var(--color-shadow);
--shadow-lg: 0 4px 8px var(--color-shadow);
}
/* Small devices (portrait tablets and large phones, 600px and up) */
@media (max-width: 600px) {
.cards-container {
grid-template-columns: 1fr;
background-color: #fb0;
}
.is-expanded {
grid-row: span 1;
grid-column: span 1;
}
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
transition: all 0.2s ease;
}
body {
font-family: 'Verdana', -apple-system, BlinkMacSystemFont, sans-serif;
background-color: var(--color-main-bg);
color: var(--color-text);
line-height: 1.5;
}
button {
border: solid 1px #000;
padding: var(--space-xs) var(--space-md);
cursor: pointer;
border-radius: var(--radius-sm);
background: var(--color-primary);
color: var(--color-text);
}
button:hover {
background: var(--color-secondary);
color: #fff;
}
ul {
margin-bottom: var(--space-sm);
}
li {
margin-top: calc(var(--space-xs));
margin-bottom: calc(var(--space-xs)/2);
margin-left: var(--space-md);
}
tbody {
table-layout: fixed;
}
tfoot {
table-layout: auto;
}
.float-right {
float: right;
}
.float-left {
float: left;
}
.align-self-right {
align-self: self-end;
}
.tight {
padding: 0 var(--space-sm);
margin: var(--space-xs);
}
.relative {
position: relative;
}
.fixed {
position: fixed;
}
.absolute {
position: absolute;
}
.sticky {
position: sticky;
top: 0;
}
.top {
z-index: 1;
}
.modal {
z-index: 2;
position: absolute;
background-color: #000000aa;
height: 100%;
width: 100%;
}
.modal .card {
max-width: 480px;
margin: auto;
margin-top: 4rem;
}
.top-bar {
padding: var(--space-sm);
border: 1px solid var(--color-text-secondary);
border-radius: var(--radius-lg);
background-color: var(--color-card-bg);
}
button.tiny {
min-width: 2em;
max-width: 2em;
text-align: center;
padding: 0 var(--space-xs);
margin: var(--space-xs);
}
button:disabled{
background-color: #aaa;
color: #fff;
border-color: #999;
cursor: not-allowed;
}
.flex-row {
gap: var(--space-md);
display: flex;
flex-direction: row;
}
.grow-fill {
flex-grow: 100;
}
.edit {
background-color: var(--color-edit);
}
.edit:hover {
background-color: rgb(187, 159, 0);
}
.delete {
background-color: var(--color-delete);
}
.delete:hover {
background-color: #f00;
}
.hollow {
background-color: #00000000;
border-style: solid;
border-width: 1px;
}
.hollow.primary {
border-color: var(--color-primary);
}
.hollow.edit {
border-color: var(--color-edit);
}
.hollow.delete {
border-color: var(--color-delete);
}
.primary:not(.hollow) {
background-color: var(--color-primary);
color: #000
}
.dropdown {
border-radius: 0px;
border-style: none;
border-top: solid 1px var(--color-text);
padding: 0;
cursor: pointer;
transition: background 0.3s ease;
background-color: transparent;
color: var(--color-text);
}
.dropdown:hover {
background-color: var(--color-primary);
color: #000;
}
/* Main Content */
.main-content {
flex: 1;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.is-fullwidth {
width: 100%;
}
/* Layout */
.app-container {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: clamp(180px, 20%, 250px);
background-color: var(--color-sidebar-bg);
color: white;
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-md);
resize: horizontal;
}
.sidebar-header {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
/* Preferences Pane */
.preferences-pane {
border-top: 1px solid rgba(255, 255, 255, 0.2);
padding-top: var(--space-md);
}
.prefs-toggle {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
width: 100%;
padding: var(--space-xs);
cursor: pointer;
}
.prefs-content {
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.prefs-content.active {
display: flex;
}
.prefs-section {
background: rgba(255, 255, 255, 0.1);
padding: var(--space-sm);
border-radius: var(--radius-sm);
}
.prefs-section h3 {
margin-bottom: var(--space-xs);
font-size: 0.9rem;
}
.prefs-section label {
display: flex;
flex-direction: column;
gap: var(--space-xs);
font-size: 0.85rem;
}
/* Search Bar */
.search-bar {
margin: var(--space-sm);
}
.search-bar input {
display: inline-block;
width: 16rem;
padding: var(--space-xs) var(--space-md);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 1rem;
transition: border-color 0.05s ease;
}
.search-bar input:focus {
outline: none;
border-color: var(--color-secondary);
}
.search-bar button {
display: inline-block;
background: var(--color-secondary);
color: white;
}
.search-bar button:hover {
background: #000;
}
/* Cards */
.cards-container {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: var(--space-md);
width: 100%;
}
.columned {
columns: 5em 2;
}
.card {
align-self: start;
background: var(--color-card-bg);
border-radius: var(--radius-md);
padding: var(--space-md);
box-shadow: var(--shadow-md);
transition: transform 0.05s ease, box-shadow 0.2s ease;
}
.card.is-expanded {
grid-row: span 5;
grid-column: span 2;
}
.card:hover {
outline: 1px solid var(--color-primary);
}
.card h3 {
font-size: clamp(1.1rem, 1.5vw, 1.25rem);
margin-bottom: var(--space-xs);
color: var(--color-text);
}
.card h4 {
font-size: clamp(1.rem, 1.4vw, 1.1rem);
margin-top: var(--space-md);
margin-bottom: var(--space-xs);
color: var(--color-text);
}
.card p {
color: var(--color-text-secondary);
font-size: 0.95rem;
}

View file

@ -22,6 +22,7 @@
"@feathersjs/feathers": "^5.0.6",
"@google-cloud/local-auth": "^3.0.1",
"@repo/common": "workspace:*",
"cors": "^2.8.6",
"csv-parser": "^3.2.0",
"express": "^5.1.0",
"google-auth-library": "^9.15.1",
@ -34,6 +35,7 @@
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.6.1",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/node": "^22.5.1",
"tsx": "^4.19.4",

View file

@ -114,5 +114,5 @@ export class PartOfSpeech extends BaseEntity {
long_form: string;
@Column({ nullable: false, unique: true, type: "text" })
short_form: string;
short_form: string;
}

View file

@ -1,17 +1,16 @@
import "reflect-metadata";
import { SAMPLE } from "@repo/common/sample";
import express from "express";
import crypto from "crypto"
import fs from 'fs';
;import { appDataSource } from "./config/dbconfig.js";
import { Lemma, WordForm, Lect } from "./db/dbmodel.js";
import crypto from "crypto";
import fs from "fs";
import { appDataSource } from "./config/dbconfig.js";
import { Lemma, WordForm, Lect, Definition, Example } from "./db/dbmodel.js";
import "@total-typescript/ts-reset";
import {
Like, In
} from "typeorm";
import { Like, In } from "typeorm";
import cors from "cors";
const RELOAD_SHEET_ON_START = false;
const SOURCE_FILE = 'res/sample.tsv'
const SOURCE_FILE = "res/sample.tsv";
appDataSource
.initialize()
@ -28,41 +27,41 @@ function initExpress() {
const app = express();
const PORT = 1225;
const lect_repository = appDataSource.getRepository(Lect);
const word_form_repository = appDataSource.getRepository(WordForm);
const lemma_repository = appDataSource.getRepository(Lemma);
app.use(cors(), express.json());
app.get("/sample", (_req, res) => {
res.status(200).send(SAMPLE);
});
app.get("/search", async (req, res) => {
const search_term = req.query.search_term?.toString();
app.get("/search", async (_req, res) => {
const search_term = _req.query.search_term?.toString();
let word_forms: WordForm[];
let lemmas: Lemma[];
if (!search_term) {
return void res.sendStatus(400);
lemmas = await Lemma.find({
relations: { word_forms: { lect: true } }
});
} else {
word_forms = await WordForm.find({
where: { word_form: Like(`%${search_term}%`) },
relations: { lemma: true },
});
let lemma_ids = word_forms.map((w) => w.lemma.lemma_name);
lemmas = await Lemma.find({
where: { lemma_name: In(lemma_ids) },
relations: { word_forms: { lect: true } },
});
}
const word_forms: WordForm[] = await WordForm.find({
where:{word_form: Like(`%${search_term}%`)},
relations: { lemma: true }
});
let lemma_ids = word_forms.map(w=>w.lemma.lemma_name);
const lemmas: Lemma[] = await Lemma.find({
where: { lemma_name: In(lemma_ids)},
relations: { word_forms: { lect: true }}
})
res.status(200).send({
terms: lemmas.length,
results: lemmas
});
res.status(200).send({ terms: lemmas.length, results: lemmas });
});
app.get("/lect", async (req, res) => {
const name = req.query.name?.toString();
app.get("/lect", async (_req, res) => {
const name = _req.query.name?.toString();
if (!name) {
return void res.sendStatus(400);
@ -76,11 +75,365 @@ function initExpress() {
res.status(200).send({ lect });
});
app.post("/lect", (_req, res) => {
const lect_name = _req.query.lect_name?.toString();
if (!lect_name) {
return void res.sendStatus(400);
}
const lect = new Lect();
lect.name = lect_name;
lect.save().then((lect)=>{
res.status(200).send({ lect })
});
});
app.get("/lects", async (_req, res) => {
const lects = await Lect.find();
res.status(200).send({
lects,
res.status(200).send({ lects });
});
app.get("/lemma-detail", async (_req, res) => {
const lemma_name = _req.query.lemma_name?.toString();
const lemma_detail = await Lemma.findOne({
where: {
lemma_name: lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({ lemma_detail });
});
app.post("/lemma", async (_req, res) => {
const lect_name = _req.body.lect_name ?? null;
const word_form_text = _req.body.word_form_text?.toString();
if(!word_form_text || /^\s*$/.test(word_form_text) || !lect_name){
console.error(`Error: ${JSON.stringify(_req.body)}`);
return void res.status(400).send();
}
let word_form = new WordForm();
let lemma = new Lemma();
let lect;
try{
lect = await Lect.findOne({where:{name:lect_name}});
} catch {
console.info(`Lect ${lect_name} not found.`);
}
if(!lect) {
lect = new Lect();
lect.name = lect_name;
await lect
.save()
.then((l)=>{console.info(`Created lect: ${JSON.stringify(l)}`)});
}
lemma.lemma_name = word_form_text;
word_form.word_form = word_form_text;
word_form.lemma = lemma;
word_form.lect = lect;
lemma.word_forms = [word_form];
Lemma.save(lemma);
let lemma_detail = await Lemma.findOne({
where: {
lemma_name: word_form.lemma.lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({lemma_detail});
});
app.get("/definition/:definition_id", async (_req, res) =>{
const definition_id:number = parseInt(_req.params.definition_id);
if(!definition_id){
console.error(`Error: Could not find word form ${JSON.stringify({definition_id})}`);
return void res.status(400).send();
}
let definition = await Definition.findOne({where:{definition_id}}).then();
res.status(200).send({definition});
});
app.put("/definition", async (_req, res) => {
const definition_text = _req.body.definition_text?.toString();
const definition_id = _req.body.definition_id ?? null;
const lemma_name = _req.body.lemma_name?.toString();
if(definition_text == null || lemma_name == null){
console.error(`Error: ${JSON.stringify({definition_text:definition_text, lemma_name:lemma_name})}`);
return void res.status(400).send();
}
const lemma = await Lemma.findOne({where:{
lemma_name: lemma_name
}});
if(!lemma){
return void res.status(400).send();
}
let definition:Definition = new Definition();
definition.definition_text = definition_text;
definition.lemma = lemma;
if(definition_id){
definition.definition_id = definition_id;
}
Definition.save(definition)
let lemma_detail = await Lemma.findOne({
where: {
lemma_name: lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({lemma_detail});
});
app.delete("/definition/:definition_id", async (_req, res) =>{
const definition_id:number = parseInt(_req.params.definition_id);
if(!definition_id){
console.error(`Error: Could not find word form ${JSON.stringify({definition_id})}`);
return void res.status(400).send();
}
Definition.delete({definition_id: definition_id});
res.status(200).send();
});
/* examples */
app.get("/example/:example_id", async (_req, res) =>{
const example_id:number = parseInt(_req.params.example_id);
if(!example_id){
console.error(`Error: Could not find word form ${JSON.stringify({example_id})}`);
return void res.status(400).send();
}
let example = await Example.findOne({where:{example_id}}).then();
res.status(200).send({definition: example});
});
app.put("/example", async (_req, res) => {
const example_text = _req.body.example_text?.toString();
const example_id = _req.body.example_id ?? null;
const lemma_name = _req.body.lemma_name?.toString();
if(!example_text || !lemma_name){
return void res.status(400).send();
}
const lemma = await Lemma.findOne({where:{
lemma_name
}});
if(!lemma){
return void res.status(400).send();
}
var example:Example = new Example();
example.example_text = example_text;
example.lemma = lemma;
if(example_id){
example.example_id = example_id;
}
Example.save(example)
var lemma_detail = await Lemma.findOne({
where: {
lemma_name: lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({lemma_detail});
});
app.delete("/example/:example_id", async (_req, res) =>{
const example_id:number = parseInt(_req.params.example_id);
if(!example_id){
console.error(`Error: Could not find word form ${JSON.stringify({example_id:example_id})}`);
return void res.status(400).send();
}
Example.delete({example_id: example_id});
res.status(200).send();
});
/* word forms */
app.put("/word-form", async (_req, res) => {
const word_form_text = _req.body.word_form_text?.toString();
const word_form_id = _req.body.word_form_id ?? null;
const lemma_name = _req.body.lemma_name ?? null;
if(!word_form_text || /^\s*$/.test(word_form_text) || !word_form_id){
console.error(`Error: ${JSON.stringify({word_form_text:word_form_text, word_form_id:word_form_id})}`);
return void res.status(400).send();
}
let word_form = await WordForm.findOne({where:{
word_form_id: word_form_id
}, relations:{
lemma: true
}});
if(!word_form){
if(lemma_name){
word_form = new WordForm();
let lemma = await Lemma.findOne({where:{lemma_name}});
}
console.error(`Failed to find word form with ID ${word_form_id}`)
return void res.status(400).send();
}
word_form.word_form = word_form_text;
WordForm.save(word_form);
let lemma_detail = await Lemma.findOne({
where: {
lemma_name: word_form.lemma.lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({lemma_detail});
});
app.post("/word-form", async (_req, res) => {
const lemma_name = _req.body.lemma_name ?? null;
const lect_name = _req.body.lect_name ?? null;
const word_form_text = _req.body.word_form_text?.toString();
if(!word_form_text || /^\s*$/.test(word_form_text) || !lect_name || !lemma_name){
console.error(`Error: ${JSON.stringify(_req.body)}`);
return void res.status(400).send();
}
let word_form = new WordForm();
let lemma = await Lemma.findOne({where:{lemma_name}});
let lect;
try{
lect = await Lect.findOne({where:{name:lect_name}});
} catch {
console.info(`Lect ${lect_name} not found.`);
}
if(!lemma){
console.error(`Error: ${JSON.stringify({lect:lect, lemma:lemma})}`);
return void res.status(400).send();
}
if(!lect) {
lect = new Lect();
lect.name = lect_name;
await lect
.save()
.then((l)=>{console.info(`Created lect: ${JSON.stringify(l)}`)});
}
word_form.word_form = word_form_text;
word_form.lemma = lemma;
word_form.lect = lect;
WordForm.save(word_form);
let lemma_detail = await Lemma.findOne({
where: {
lemma_name: word_form.lemma.lemma_name
},
relations: {
word_forms: {
lect: true
},
examples: true,
definitions: true,
media: true,
parts_of_speech: true
}
});
res.status(200).send({lemma_detail});
});
app.delete("/word-form/:word_form_id", async (_req, res) =>{
const word_form_id:number = parseInt(_req.params.word_form_id);
if(!word_form_id){
console.error(`Error: Could not find word form ${JSON.stringify({word_form_id:word_form_id})}`);
return void res.status(400).send();
}
WordForm.delete({word_form_id: word_form_id});
res.status(200).send();
});
app.listen(PORT, () => {
@ -98,18 +451,17 @@ async function loadSheet() {
await lemma_repository.clear();
await lect_repository.clear();
const rawData: string = fs.readFileSync(SOURCE_FILE, 'utf8');
const rows: string[] = rawData.split('\n');
const rawData: string = fs.readFileSync(SOURCE_FILE, "utf8");
const rows: string[] = rawData.split("\n");
if (!rows || rows.length === 0) {
console.error("No data found.");
return;
}
const lect_names = rows.shift()?.split('\t');
const lect_names = rows.shift()?.split("\t");
const keys = rows.map(row=>row.split('\t')[0]?.split(';')[0]);
const keys = rows.map((row) => row.split("\t")[0]?.split(";")[0]);
console.log(keys);
if (keys.length != rows.length) {
@ -128,27 +480,27 @@ async function loadSheet() {
let l = new Lect();
l.name = lect;
l.save();
lects.push(l)
lects.push(l);
console.log(l);
}
const lemmas = Array<Lemma>();
for (let i = 0; i < rows.length; i++) {
const row = rows[i]?.split('\t');
const row = rows[i]?.split("\t");
const lect_name = lect_names[i];
if(!lect_name){
if (!lect_name) {
console.error("No lect name");
break;
}
if(!row){
if (!row) {
console.error("Row doesn't exist");
continue;
}
if(row.length !== lect_names.length){
if (row.length !== lect_names.length) {
console.error("Mismatched row size");
continue;
}
@ -175,10 +527,10 @@ async function loadSheet() {
const lect = lects[j];
if (
cell === null ||
cell === undefined ||
(typeof cell === "string" && cell.length === 0) ||
!lect
cell === null
|| cell === undefined
|| (typeof cell === "string" && cell.length === 0)
|| !lect
) {
continue;
}

View file

@ -1,62 +0,0 @@
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 defineConfig([
globalIgnores(["dist"]),
{
extends: [
js.configs.recommended,
ts.configs.strictTypeChecked,
...vue.configs["flat/essential"],
],
files: ["./src/**/*.{js,ts,vue}"],
plugins: { vue },
languageOptions: {
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,19 +0,0 @@
<!doctype html>
<html lang="en" class="has-navbar-fixed-top">
<head>
<meta charset="UTF-8" />
<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" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Viossa.net</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,43 +0,0 @@
{
"name": "@repo/vdn-static",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fluent/bundle": "^0.19.1",
"@tailwindcss/vite": "^4.1.6",
"@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"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@repo/common": "workspace:*",
"@vitejs/plugin-vue": "^5.2.3",
"@vue/tsconfig": "^0.7.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.55.0",
"unplugin-vue-router": "^0.12.0",
"vite": "^6.3.5",
"vue-eslint-parser": "^10.2.0",
"vue-tsc": "^2.2.8"
},
"packageManager": "pnpm@10.11.0"
}

View file

@ -1,26 +0,0 @@
<ns0:svg xmlns:ns0="http://www.w3.org/2000/svg" version="1.1" id="Layer_1" x="0px" y="0px" width="144.0" height="144.0" viewBox="56.0 56.0 144.0 144.0" style="enable-background:new 0 0 256 256;" xml:space="preserve">
<ns0:style type="text/css">
.Drop_x0020_Shadow{fill:none;}
.Thick_x0020_Blue_x0020_Neon{fill:none;stroke:#0073BC;stroke-width:10;stroke-linecap:round;stroke-linejoin:round;}
.Illuminating_x0020_Aqua{fill:url(#SVGID_1_);stroke:#FFFFFF;stroke-width:0.25;stroke-miterlimit:1;}
.Black_x0020_Highlight{fill:url(#SVGID_00000121249889455812460230000004963949080483821184_);stroke:#FFFFFF;stroke-width:0.362861;stroke-miterlimit:1;}
.Bugaboo_GS{fill-rule:evenodd;clip-rule:evenodd;fill:#FFDD00;}
.st0{fill:#4DABF7;}
.st1{fill:#F8F9FA;}
</ns0:style>
<ns0:linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="6.123234e-17" y2="-1">
<ns0:stop offset="0" style="stop-color:#1D59F4" />
<ns0:stop offset="0.617977" style="stop-color:#2D65EE" />
<ns0:stop offset="0.629213" style="stop-color:#3864F3" />
<ns0:stop offset="0.983146" style="stop-color:#00DDFC" />
</ns0:linearGradient>
<ns0:linearGradient id="SVGID_00000029744916847460459310000011004185732043246012_" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="6.123234e-17" y2="-1">
<ns0:stop offset="0" style="stop-color:#060606" />
<ns0:stop offset="0.617977" style="stop-color:#000000" />
<ns0:stop offset="0.629213" style="stop-color:#000000" />
<ns0:stop offset="0.983146" style="stop-color:#000000" />
</ns0:linearGradient>
<ns0:circle class="st0" cx="128" cy="128" r="72" />
<ns0:path class="st1" d="M187.6656494,101.3799362h1.3769073l-0.4589691,1.1474228l-2.2948303,2.0653458l-3.2127686,2.7537994 l-3.4422455,2.983284l-3.2127686,2.5243149l-2.9832916,2.0653534l-11.4741516,6.8844986l-4.8191528,3.67173l-4.5896606,3.4422531 l-3.9012299,2.7537994l-2.2948303,2.7537994l-1.6063843,2.5243073l1.6063843-0.4589691h0.9179382l-0.4589691,1.1474152 l-1.3768921,1.6063843l-2.9832916,2.2948456l-0.917923,0.688446l-0.6884613,5.5075989l-1.6063843,3.2127686l-2.5243073,2.7537994 l-2.0653534,0.917923h-2.2948303l-2.2948303-0.917923l-1.8358765-2.0653534l-2.7537918-4.5896606l-2.0653534-2.2948456 l-2.2948303-1.6063843l-3.67173-1.8358612l-2.983284-0.9179382l-9.8677826-1.606369l-8.9498444-2.0653534l-15.8343506-3.9012146 l-4.8191452-1.6063843l-3.6717377-1.8358765l-3.2127609-2.0653381l-1.1474228-1.3768997l0.2294846-0.2294846h2.294838 l8.7203598,2.7537994l9.8677826,2.2948303l5.7370834,0.9179382l12.1626129,0.2294769l5.2781143,0.688446l5.5075989,1.1474152 l3.9012146,1.3769073l2.983284,1.6063843l2.5243149,1.8358612l1.1474228,0.688446h2.2948303l5.2781067-0.917923l2.9832916-0.2294922 l1.6063843-0.688446l2.5243073-4.3601837l2.2948456-3.2127686l2.7537994-2.7537918l3.9012146-2.983284l5.7370758-3.4422531 l5.5075989-2.7537994l4.3601837-2.0653458l5.2781219-3.4422531l3.6717224-2.5243149l3.6717377-2.7537994l2.9832764-2.2948303 l3.9012146-2.5243149L187.6656494,101.3799362z" />
</ns0:svg>

View file

@ -1,106 +0,0 @@
<script setup lang="ts">
import "./assets/style.scss";
import { computed, ref, type Ref } from "vue";
import LocalePicker from "./components/organisms/LocalePicker.vue";
import { vOnClickOutside } from "@vueuse/components";
import { useRouter } from "vue-router";
import SmartLink from "./components/atoms/SmartLink.vue";
import type { SmartDest } from "./utils/smart-dest";
import { useLocale, type Locale } from "./new-i18n";
const locale = useLocale();
const burgerOpen: Ref<boolean> = ref<boolean>(false);
const toggleBurger = (): void => {
burgerOpen.value = !burgerOpen.value;
};
const closeBurger = (): void => {
burgerOpen.value = false;
};
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>
<div class="min-h-screen flex flex-col" v-on-click-outside="closeBurger">
<!-- Main application wrapper -->
<nav
class="navbar is-fixed-top"
role="navigation"
aria-label="main navigation">
<div class="navbar-brand">
<SmartLink
class="navbar-item has-text-weight-bold"
:to="{ type: 'internal', internal: { route: '/' } }">
<img src="@/assets/ViossaFlagRect.svg" alt="" />
</SmartLink>
<div class="navbar-item is-hidden-desktop">
<button
type="button"
@click="toggleBurger()"
:class="`button is-link is-hoverable is-hidden-desktop ${burgerOpen ? 'is-active' : ''}`"
aria-label="menu"
:aria-expanded="`${burgerOpen ? 'true' : 'false'}`">
<span class="bx bx-burger"></span>
</button>
</div>
</div>
<div :class="`navbar-menu ${burgerOpen ? 'is-active' : ''}`">
<div class="navbar-start">
<SmartLink
v-for="(item, index) in navbarItems"
:key="index"
class="navbar-item"
:to="item.to"
>{{ item.label }}
</SmartLink>
<LocalePicker class="navbar-item" />
</div>
</div>
</nav>
<RouterView />
</div>
</template>

View file

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

View file

@ -1,75 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="360mm"
height="360mm"
viewBox="0 0 360 360"
version="1.1"
id="svg5"
xml:space="preserve"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="viossaFlag.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="5.7299663"
inkscape:cx="86.126161"
inkscape:cy="613.87795"
inkscape:window-width="2400"
inkscape:window-height="1411"
inkscape:window-x="2391"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="layer2"><inkscape:grid
type="xygrid"
id="grid302" /></sodipodi:namedview><defs
id="defs2" /><g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
transform="translate(134.0933,87.179113)"><path
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.0289973;stroke-miterlimit:22.2;stroke-dasharray:none;stroke-opacity:1"
id="path6216"
sodipodi:type="arc"
sodipodi:cx="45.906708"
sodipodi:cy="92.820892"
sodipodi:rx="180"
sodipodi:ry="180"
sodipodi:start="4.6934522"
sodipodi:end="4.6302294"
sodipodi:open="true"
sodipodi:arc-type="arc"
d="M 42.498291,-87.146834 A 180,180 0 0 1 225.79809,86.568504 180,180 0 0 1 55.001506,272.59098 180,180 0 0 1 -133.69718,104.75583 180,180 0 0 1 31.134615,-86.571932"
inkscape:export-filename="..\Finished\ViossaFlagCirc3_2ratio.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96" /><g
id="g5819"
transform="matrix(0.93405356,0,0,0.93405356,-53.016663,-34.779538)"><path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.399354;stroke-miterlimit:22.2"
id="path385"
sodipodi:type="arc"
sodipodi:cx="105.9076"
sodipodi:cy="136.60933"
sodipodi:rx="128.47229"
sodipodi:ry="128.47374"
sodipodi:start="4.6934522"
sodipodi:end="4.6302294"
sodipodi:open="true"
sodipodi:arc-type="arc"
d="M 103.47489,8.1586244 A 128.47229,128.47374 0 0 1 234.30236,132.14673 128.47229,128.47374 0 0 1 112.39888,264.91897 128.47229,128.47374 0 0 1 -22.281972,145.12781 128.47229,128.47374 0 0 1 95.364242,8.5689572" /><path
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.029;stroke-miterlimit:22.2;stroke-dasharray:none"
d="m 117.05457,164.17654 c 8.03452,-0.0462 8.49627,-1.08512 8.49627,-1.08512 9.82856,-19.81483 22.21219,-26.80177 41.97341,-36.29385 8.34356,-3.68297 19.0164,-10.96258 27.61288,-17.13106 6.26354,-4.49445 12.05177,-10.297107 20.17864,-13.621734 8.53145,-3.490137 -10.80504,12.328824 -10.80504,12.328824 -9.0042,8.12686 -14.95674,11.81852 -22.02565,15.97668 -10.7129,6.30168 -14.31359,8.34425 -19.30131,12.14412 -5.80492,4.42245 -9.88634,7.92764 -13.66791,10.29711 -7.29571,4.57136 -9.28125,7.66511 -10.11241,8.77332 -0.83116,1.10821 -3.97108,5.95663 -3.97108,5.95663 1.66231,-0.50793 4.94076,-1.01586 4.94076,-1.01586 0.0462,2.40112 -5.49486,6.7416 -5.49486,6.7416 -2.77053,1.89319 -5.77193,4.47901 -5.77193,4.47901 1.20056,11.49767 -5.58722,18.0084 -5.58722,18.0084 -9.28124,10.20475 -18.3427,0.76386 -20.22481,-4.20196 -3.17843,-8.38609 -13.802587,-15.78912 -23.687967,-17.82369 -11.028807,-2.26991 -14.129558,-1.84483 -21.05597,-3.46315 -19.763059,-4.61754 -25.561421,-6.75206 -38.510261,-9.37361 -7.526586,-1.52379 -13.8064366,-4.20196 -13.8064366,-4.20196 0,0 -10.8973881,-4.84841 -13.5293844,-8.35774 -2.6319962,-3.50933 7.20335825,-0.3694 7.20335825,-0.3694 0,0 5.60046125,2.09797 8.49626865,2.81669 19.1627801,4.75607 27.5461821,6.95957 33.8465491,7.24953 6.300367,0.29001 17.394868,0.27841 17.394868,0.27841 17.211282,0.25025 32.426083,4.51346 41.247855,12.11969 1.92916,1.66334 3.41698,1.73157 4.42129,1.92782 3.28987,0.10945 8.16149,-1.974 11.74009,-2.1587 z"
id="path455"
sodipodi:nodetypes="cczccscccsscccccsssscssccscc" /></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

View file

@ -1,70 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="508mm"
height="341.04791mm"
viewBox="0 0 508 341.04791"
version="1.1"
id="svg5"
xml:space="preserve"
inkscape:version="1.2.2 (732a01da63, 2022-12-09)"
sodipodi:docname="viossaFlag.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
showgrid="false"
inkscape:zoom="5.7299663"
inkscape:cx="86.126161"
inkscape:cy="613.87795"
inkscape:window-width="2400"
inkscape:window-height="1411"
inkscape:window-x="2391"
inkscape:window-y="-9"
inkscape:window-maximized="1"
inkscape:current-layer="layer2"><inkscape:grid
type="xygrid"
id="grid302" /></sodipodi:namedview><defs
id="defs2" /><g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Layer 2"
transform="translate(150.306,34.583659)"><g
id="g5950"
inkscape:export-filename="..\Finished\ViossaFlagFullSize.svg"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"><rect
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.0615961;stroke-miterlimit:22.2;stroke-dasharray:none;stroke-opacity:1"
id="rect5873"
width="508"
height="341.04791"
x="-150.306"
y="-34.58366" /><g
id="g5819"
transform="translate(-2.2136002,-0.66903305)"><path
style="fill:#ffffff;fill-opacity:1;stroke:none;stroke-width:0.399354;stroke-miterlimit:22.2"
id="path385"
sodipodi:type="arc"
sodipodi:cx="105.9076"
sodipodi:cy="136.60933"
sodipodi:rx="128.47229"
sodipodi:ry="128.47374"
sodipodi:start="4.6934522"
sodipodi:end="4.6302294"
sodipodi:open="true"
sodipodi:arc-type="arc"
d="M 103.47489,8.1586244 A 128.47229,128.47374 0 0 1 234.30236,132.14673 128.47229,128.47374 0 0 1 112.39888,264.91897 128.47229,128.47374 0 0 1 -22.281972,145.12781 128.47229,128.47374 0 0 1 95.364242,8.5689572" /><path
style="fill:#00bbff;fill-opacity:1;stroke:none;stroke-width:0.029;stroke-miterlimit:22.2;stroke-dasharray:none"
d="m 117.05457,164.17654 c 8.03452,-0.0462 8.49627,-1.08512 8.49627,-1.08512 9.82856,-19.81483 22.21219,-26.80177 41.97341,-36.29385 8.34356,-3.68297 19.0164,-10.96258 27.61288,-17.13106 6.26354,-4.49445 12.05177,-10.297107 20.17864,-13.621734 8.53145,-3.490137 -10.80504,12.328824 -10.80504,12.328824 -9.0042,8.12686 -14.95674,11.81852 -22.02565,15.97668 -10.7129,6.30168 -14.31359,8.34425 -19.30131,12.14412 -5.80492,4.42245 -9.88634,7.92764 -13.66791,10.29711 -7.29571,4.57136 -9.28125,7.66511 -10.11241,8.77332 -0.83116,1.10821 -3.97108,5.95663 -3.97108,5.95663 1.66231,-0.50793 4.94076,-1.01586 4.94076,-1.01586 0.0462,2.40112 -5.49486,6.7416 -5.49486,6.7416 -2.77053,1.89319 -5.77193,4.47901 -5.77193,4.47901 1.20056,11.49767 -5.58722,18.0084 -5.58722,18.0084 -9.28124,10.20475 -18.3427,0.76386 -20.22481,-4.20196 -3.17843,-8.38609 -13.802587,-15.78912 -23.687967,-17.82369 -11.028807,-2.26991 -14.129558,-1.84483 -21.05597,-3.46315 -19.763059,-4.61754 -25.561421,-6.75206 -38.510261,-9.37361 -7.526586,-1.52379 -13.8064366,-4.20196 -13.8064366,-4.20196 0,0 -10.8973881,-4.84841 -13.5293844,-8.35774 -2.6319962,-3.50933 7.20335825,-0.3694 7.20335825,-0.3694 0,0 5.60046125,2.09797 8.49626865,2.81669 19.1627801,4.75607 27.5461821,6.95957 33.8465491,7.24953 6.300367,0.29001 17.394868,0.27841 17.394868,0.27841 17.211282,0.25025 32.426083,4.51346 41.247855,12.11969 1.92916,1.66334 3.41698,1.73157 4.42129,1.92782 3.28987,0.10945 8.16149,-1.974 11.74009,-2.1587 z"
id="path455"
sodipodi:nodetypes="cczccscccsscccccsssscssccscc" /></g></g></g></svg>

Before

Width:  |  Height:  |  Size: 4.2 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

View file

@ -1,77 +0,0 @@
localeName = "English"
vilanticLangs-viossa = "Viossa"
vilanticLangs-wodox = "Wodoch"
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 = "Originally started in 2015 something something read the rules here, then click the link below to join!"
resources-resources-discord-buttons-join-label = "Join"
resources-resources-discord-buttons-rules-label = "Rules"
resources-images-discordLogo-alt = "Discord logo"
kotoba-title = "Tropos-agnostic search"
kotoba-searchHelp = "To searcn tropos-agnostically, enter a term below."
discord-rulesPage-title = "Discord Server Rules"
discord-rulesPage-overview-title = "Overview"
discord-rulesPage-overview-help = "Click any rule to see details."
discord-rulesPage-rules-noTranslation-overview-text = 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

@ -1,3 +0,0 @@
localeName = "Viossa"
home-sections-whatIsViossa-title = "Kafaen afto Viossa"
home-sections-whatIsViossa-body = "Viossa tte glossa mahena grun vi nai vil fshtojena na bakadjin, grun vi svinnur ja! De aldjin zovti lera ne"

View file

@ -1,22 +0,0 @@
localeName = "wodox"
vilanticLangs-viossa = "viosox"
vilanticLangs-wodox = "wodox"
navbar-whatIsViossa = "viosox e ano?"
navbar-resources = "tropos"
navbar-kotoba = "mot o viosox"
home-sections-whatIsViossa-title = "viosox e ano?"
home-sections-whatIsViossa-body = "viosox e hez ox pamzal, zoz stende zalkun tuo mit multa nengwi ox. zal o viosox stende lik zal o hez il ox keta, zalilkun wi tuo mit multa nengwi ox. mono i fal o viosox stendenai; omni axsi o viosox zal nengokun fal o viosox, de falmot wi falax o il stende e keko trenengwi tua o nengwi stende, ge fala e keko lik ro o tuo viosoxsi. genil viosox ibe il wi nengwi stende axkun ge pisakun po tuo ox — stende gen muskunnai mit zaiox."
home-sections-historyOfViossa-title = "zal o viosox"
home-sections-historyOfViossa-body = "wi o zal o viosox stende po multa o Skype wi 2014 ibe stendera o multa r/conlangs o Reddit. zalsi o viosox danzalgo hez, tuo zal o viosox e lik zal o hez il ox keta, zalil tuo ox mit nengwi multa ox ibe zalsi fiemnaikun sama i ox. viosox e nengwi tuo ox pamzal keta; zalilkun keko ox keta mit lik du wi tre ox, aga zalil viosox mit multa wi plus obo o ox na il ox keta ibe zalsi o viosox stende po multa mi o mo."
home-sections-community-title = "viosoxsi"
home-sections-community-body = "nengwi multa ro o viosoxsi stende ibe stendenura po nengwi multa mi o mo ge wekakunnura zai nengwi viosoxsi po jilobo. ibe mono i fal o viosox stendenai ge ibe viosoxsi zalkun nengwi multa fal o viosox, de nengwi zoz ko o ro lik ro o viosoxsi stende po ro o viosa. po multa hez viosoxsi, falmot wi falax o tuo stende stende po ro o tuo stende. ibe mono i fal o viosox stendenai ge ibe ro o mot inkun nengwi po nengwi viosoxsi, de multa stende amanata hez, zal zalgonukun surat au mola au sucik."
home-images-viossaFlag-alt = "fomma o viosox"
resources-title = "tropos o gen"
resources-resources-discord-title = "server o Diskord"
resources-resources-discord-subtitle = "axilkun ge genilkun po ce! wekatutsa!"
resources-resources-discord-desc = "danzalil hez server po 2015. ibe dutukun musra po ce, de ibe wiftutsakun dof po pam, de wekatukun po server!"
resources-resources-discord-buttons-join-label = "wekatutsa"
resources-resources-discord-buttons-rules-label = "musra"
resources-images-discordLogo-alt = "surat o Diskord"
kotoba-title = "zalkuketutsa mot o viosox mit il o omni falmot"
kotoba-searchHelp = "ibe tastatukun il falmot o mot o viosox po pam, de zalkuketukun."

View file

@ -1,28 +0,0 @@
@use "./bulma.css";
* {
--bulma-primary-h: 196deg;
--bulma-primary-l: 50%;
--bulma-primary-s: 100%;
--bulma-link-h: 293deg;
--bulma-link-l: 50%;
--bulma-link-s: 45%;
--bulma-warning-h: 31deg;
--bulma-warning-l: 75%;
--bulma-warning-s: 100%;
--bulma-info-h: 90deg;
--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;
}
// Correct for fixed-top header when jumping to element by id in link (anchor)
html {
scroll-padding-top: var(--bulma-navbar-height);
}

View file

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View file

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

@ -1,115 +0,0 @@
<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 "@/new-i18n-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[] }>();
console.log(Object.entries(props.markdown));
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

@ -1,67 +0,0 @@
<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 "@/new-i18n-lib/markdown";
defineProps<{
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments
elements: DeepReadonly<MarkdownSpan<Slot>[]>;
slots: DeepReadonly<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

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

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

View file

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

View file

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

@ -1,9 +0,0 @@
# What are atoms?
https://bradfrost.com/blog/post/atomic-web-design/
Atoms are the basic building blocks of matter. Applied to web interfaces, atoms are our HTML tags, such as a form label, an input or a button.
Atoms can also include more abstract elements like color palettes, fonts and even more invisible aspects of an interface like animations.
Like atoms in nature theyre fairly abstract and often not terribly useful on their own. However, theyre good as a reference in the context of a pattern library as you can see all your global styles laid out at a glance.

View file

@ -1,38 +0,0 @@
<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 "@/new-i18n";
import type { DeepReadonly } from "vue";
import { isEmptyMarkdown } from "@/new-i18n-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

@ -1,20 +0,0 @@
<script setup lang="ts">
import type { Locale } from "@/new-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,37 +0,0 @@
<script setup lang="ts">
defineProps<{
title: string;
text: string;
image?: string;
alt?: string;
reverse: boolean;
}>();
</script>
<template>
<div class="box my-5 px-4 py-3 columns is-vcentered">
<template v-if="reverse">
<div class="column">
<h2 class="title is-4">{{ title }}</h2>
<p>{{ text }}</p>
</div>
<div class="column is-one-quarter" v-if="image">
<figure class="image">
<img :src="image" :alt="alt" :title="alt" />
</figure>
</div>
</template>
<template v-else>
<div class="column is-one-quarter" v-if="image">
<figure class="image">
<img :src="image" :alt="alt" :title="alt" />
</figure>
</div>
<div class="column">
<h2 class="title is-4">{{ title }}</h2>
<p>{{ text }}</p>
</div>
</template>
</div>
</template>

View file

@ -1,68 +0,0 @@
<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.src" :alt="image.alt" :title="image.alt" />
</figure>
</div>
<div class="column">
<h4 class="title">{{ title }}</h4>
<p class="subtitle">{{ subtitle }}</p>
<p class="content">{{ desc }}</p>
<div class="level">
<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>

View file

@ -1,9 +0,0 @@
<template>
<div class="box my-6">
<slot />
</div>
</template>
<script lang="ts">
export default { name: "PaddingWrapper" };
</script>

View file

@ -1,9 +0,0 @@
# What are molecules?
https://bradfrost.com/blog/post/atomic-web-design/
Things start getting more interesting and tangible when we start combining atoms together. Molecules are groups of atoms bonded together and are the smallest fundamental units of a compound. These molecules take on their own properties and serve as the backbone of our design systems.
For example, a form label, input or button arent too useful by themselves, but combine them together as a form and now they can actually do something together.
Building up to molecules from atoms encourages a “do one thing and do it well” mentality. While molecules can be complex, as a rule of thumb they are relatively simple combinations of atoms built for reuse.

View file

@ -1,56 +0,0 @@
<script setup lang="ts">
import { LOCALE_IDS, localeId, useLocale, type LocaleId } from "@/new-i18n";
import { ref } from "vue";
import { vOnClickOutside } from "@vueuse/components";
import DropdownItem from "../atoms/DropdownItem.vue";
const locale = useLocale();
const isOpen = ref<boolean>(false);
const toggleOpen = (): void => {
isOpen.value = !isOpen.value;
};
const close = (): void => {
isOpen.value = false;
};
const setLocaleId = (id: LocaleId): void => {
localeId.value = id;
close();
};
</script>
<template>
<div
:class="['dropdown', isOpen && 'is-active']"
v-on-click-outside="close">
<div class="dropdown-trigger">
<button
class="button"
aria-haspopup="true"
aria-controls="dropdown-menu"
@click="toggleOpen()">
<span>{{ locale.localeName() }}</span>
<span class="icon is-small">
<i class="fas fa-angle-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="dropdown-menu" role="menu">
<div class="dropdown-content">
<DropdownItem
v-for="(id, index) in LOCALE_IDS"
:key="index"
:class="[
'dropdown-item is-clickable',
localeId === id && 'is-active',
]"
@click="setLocaleId(id)">
{{ useLocale({ locale: id }).value.localeName() }}
</DropdownItem>
</div>
</div>
</div>
</template>

View file

@ -1,3 +0,0 @@
<script setup></script>
<template></template>

View file

@ -1,11 +0,0 @@
# What are organisms?
https://bradfrost.com/blog/post/atomic-web-design/
Molecules give us some building blocks to work with, and we can now combine them together to form organisms. Organisms are groups of molecules joined together to form a relatively complex, distinct section of an interface.
Were starting to get increasingly concrete. A client might not be terribly interested in the molecules of a design system, but with organisms we can see the final interface beginning to take shape. Dan Mall (who Im working with on several projects) uses element collages, which articulate ideas for a few key organisms to facilitate client conversations and shape the visual direction (all without having to construct full comps).
Organisms can consist of similar and/or different molecule types. For example, a masthead organism might consist of diverse components like a logo, primary navigation, search form, and list of social media channels. But a “product grid” organism might consist of the same molecule (possibly containing a product image, product title and price) repeated over and over again.
Building up from molecules to organisms encourages creating standalone, portable, reusable components.

View file

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

View file

@ -1,514 +0,0 @@
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, FluentVariable>;
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: 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;
}
return () => `[#${fmtMessageIdChain(valueMessageIdChain)}#]`;
} 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,
configMarkdown: configValue,
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, FluentVariable> = {}) => {
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
throw new Error(
`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
throw new Error(
`Failed to parse string after compilation!\n${res.err}`,
);
}
return res.ok;
},
};
}
interface CompileMarkdownMessageCtx {
bundle: FluentBundle;
messageIdChain: readonly [...string[], string];
configMarkdown: ConfigMarkdown<object, object>;
allVariants: readonly PatternVariant[];
pattern: Pattern;
}
function compileMarkdownMessage(
ctx: CompileMarkdownMessageCtx,
): Result<GenericMarkdownMessageFn, string> {
const { bundle, messageIdChain, configMarkdown, allVariants, pattern } =
ctx;
// typecheck markdown
const markdownSlots = configMarkdown.features.slots;
// 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,
Object.keys(markdownSlots),
);
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, FluentVariable> = {}) => {
const markdownLiteralRes = parseMessageLiteral(
"md",
bundle.formatPattern(pattern, args),
);
if (markdownLiteralRes.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above
throw new Error(
`Failed to parse markdown literal after compilation!\n${markdownLiteralRes.err}`,
);
}
const markdownLiteral = markdownLiteralRes.ok;
const res = parseMarkdown(
markdownLiteral,
Object.keys(markdownSlots),
);
if (res.type === "err") {
// This should hopefully never happen since we've already
// verified all message variants parse as valid markdown above
throw new Error(
`Failed to parse markdown after compilation!\n${res.err}`,
);
}
return res.ok;
},
};
}
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

@ -1,119 +0,0 @@
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;
features: ConfigMarkdownFeatures<Slots>;
}
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 interface ConfigMarkdownFeatures<
Slots extends Partial<Record<string, ConfigSlotInfo>>,
> {
bold?: boolean;
italic?: boolean;
header?: boolean;
link?: boolean;
ulist?: boolean;
slots: Slots;
}
export function string<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
>(
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
): ConfigString<Placeables> {
return {
[configMessageTypeSymbol]: configStringSymbol,
placeables: opt.placeables,
};
}
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 {
[configMessageTypeSymbol]: configMarkdownSymbol,
placeables: opt.placeables,
features: opt.features,
};
}
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

@ -1,810 +0,0 @@
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: MarkdownElement<Slot>[];
slots: Slot[];
}
export type MarkdownElement<Slot extends string = string> =
| { type: "paragraph"; paragraph: { spans: MarkdownSpan<Slot>[] } }
| { type: "header"; header: { spans: MarkdownSpan<Slot>[] } }
| { type: "ulist"; ulist: { items: MarkdownSpan<Slot>[][] } };
type MarkdownLine<Slot extends string = string> = {
type: "paragraph" | "header" | "ulistItem";
spans: MarkdownSpan<Slot>[];
};
export type MarkdownFeature = "header" | "ulist" | "italic" | "bold" | "link";
export type MarkdownSpan<Slot extends string = string> =
| { type: "plain"; plain: string }
| { type: "italic"; italic: MarkdownSpan[] }
| { type: "bold"; bold: MarkdownSpan[] }
| {
type: "link";
link: { label: MarkdownSpan[]; to: SmartDest; newTab: boolean };
}
| { type: "slot"; slot: Slot };
export function parseMarkdown<Slot extends string>(
markdownString: string,
slots: readonly 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: 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: [...slots] } };
}
function parseMarkdownLines<Slot extends string>(
markdownString: string,
slots: readonly 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: readonly Slot[],
): Result<MarkdownLine<Slot>, string> {
if (line.startsWith("#")) {
const elementsRes = parseMarkdownSpans(line.substring(1), slots);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing header spans:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "header", spans: elements } };
}
if (line.startsWith("-")) {
const elementsRes = parseMarkdownSpans(line.substring(1), slots);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing ulist item spans:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "ulistItem", spans: elements } };
}
const elementsRes = parseMarkdownSpans(line, slots);
if (elementsRes.type === "err") {
return {
type: "err",
err: `While parsing elements:\n${elementsRes.err}`,
};
}
const elements = elementsRes.ok;
return { type: "ok", ok: { type: "paragraph", spans: elements } };
}
function parseMarkdownSpans<Slot extends string>(
line: string,
slots: readonly 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 trimmedLine = line.trim();
const chars = trimmedLine.split("");
const elementsRes = readMarkdownSpans(chars, slots, {
inItalic: false,
inBold: false,
});
if (elementsRes.type === "err") {
return elementsRes;
}
const elements = elementsRes.ok;
return { type: "ok", ok: elements };
}
interface ReadMarkdownSpanCtx {
inItalic: boolean;
inBold: boolean;
}
function readMarkdownSpans<Slot extends string>(
chars: string[],
slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx,
): Result<MarkdownSpan<Slot>[], string> {
const elements: MarkdownSpan<Slot>[] = [];
while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
}
return { type: "ok", ok: elements };
}
function readMarkdownSpan<Slot extends string>(
chars: string[],
slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx,
): Result<MarkdownSpan<Slot>[], string> {
const [firstChar, secondChar] = chars;
if (firstChar === undefined) {
return { type: "ok", ok: [] };
} else if (firstChar === "<") {
return readMarkdownSpanSlot(chars, slots);
} else if (firstChar === "[") {
return readMarkdownSpanLink(chars, slots, ctx);
} else if (firstChar === "*" && secondChar === "*" && !ctx.inBold) {
return readMarkdownSpanBold(chars, slots, ctx);
} else if (firstChar === "*" && secondChar !== "*" && !ctx.inItalic) {
return readMarkdownSpanItalic(chars, slots, ctx);
} else {
return readMarkdownSpanPlain(chars);
}
}
function isInArray<T, U extends T>(value: T, array: readonly U[]): value is U {
// SAFETY: this is just an equality check, it is safe to pass in any value
return array.includes(value as U);
}
function readMarkdownSpanSlot<Slot extends string>(
chars: string[],
slots: readonly 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 (!isInArray(slotName, slots)) {
return { type: "err", err: `Unexpected slot name: ${slotName}` };
}
return { type: "ok", ok: [{ type: "slot", slot: slotName }] };
}
function readMarkdownSpanLink<Slot extends string>(
chars: string[],
slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx,
): Result<MarkdownSpan<Slot>[], string> {
const openSquareRes = expectReadChar(chars, "[");
if (openSquareRes.type === "err") {
return openSquareRes;
}
const labelElementsRes = readMarkdownSpans(chars, slots, ctx);
if (labelElementsRes.type === "err") {
return labelElementsRes;
}
const closeSquareRes = expectReadChar(chars, "]");
if (closeSquareRes.type === "err") {
return closeSquareRes;
}
const labelElements = labelElementsRes.ok;
const openParenRes = expectReadChar(chars, "(");
if (openParenRes.type === "err") {
return openParenRes;
}
interface LinkProps {
dest: SmartDest;
newTab: boolean;
}
const linkPropsRes = ((): Result<LinkProps, string> => {
if (peekStringEq(chars, "external")) {
const externalRes = expectReadString(chars, "external");
if (externalRes.type === "err") {
return externalRes;
}
const dotRes = expectReadChar(chars, ".");
if (dotRes.type === "err") {
return dotRes;
}
const tabStringRes = readUntilClosing({
chars,
elementName: "link tab",
closingChar: ":",
});
if (tabStringRes.type === "err") {
return tabStringRes;
}
const tabString = tabStringRes.ok;
const newTabRes = ((): Result<boolean, string> => {
switch (tabString) {
case "new": {
return { type: "ok", ok: true };
}
case "replace": {
return { type: "ok", ok: false };
}
default: {
return {
type: "err",
err: `Expected \`replace\` or \`new\`; Found: ${tabString}`,
};
}
}
})();
if (newTabRes.type === "err") {
return newTabRes;
}
const newTab = newTabRes.ok;
const destRes = readUntilClosing({
chars,
elementName: "link dest",
closingChar: ")",
});
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 readMarkdownSpanBold<Slot extends string>(
chars: string[],
slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx,
): Result<MarkdownSpan<Slot>[], string> {
const firstStarRes = expectReadChar(chars, "*");
if (firstStarRes.type === "err") {
return firstStarRes;
}
const secondStarRes = expectReadChar(chars, "*");
if (secondStarRes.type === "err") {
return secondStarRes;
}
ctx.inBold = true;
const elements: MarkdownSpan<Slot>[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar, secondChar] = chars;
if (firstChar === "*" && secondChar === "*") {
chars.shift();
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inBold = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "bold", bold: elements }] };
} else {
return {
type: "ok",
ok: [{ type: "plain", plain: "**" }, ...elements],
};
}
}
function readMarkdownSpanItalic<Slot extends string>(
chars: string[],
slots: readonly Slot[],
ctx: ReadMarkdownSpanCtx,
): Result<MarkdownSpan<Slot>[], string> {
const starRes = expectReadChar(chars, "*");
if (starRes.type === "err") {
return starRes;
}
ctx.inItalic = true;
const elements: MarkdownSpan<Slot>[] = [];
let closed = false;
while (true) {
const elementRes = readMarkdownSpan(chars, slots, ctx);
if (elementRes.type === "err") {
return elementRes;
}
const element = elementRes.ok;
if (element.length === 0) {
break;
}
elements.push(...element);
const [firstChar] = chars;
if (firstChar === "*") {
chars.shift();
closed = true;
break;
} else if (firstChar === undefined) {
closed = false;
break;
}
}
ctx.inItalic = !closed;
if (closed) {
return { type: "ok", ok: [{ type: "italic", italic: elements }] };
} else {
return { type: "ok", ok: [{ type: "plain", plain: "*" }, ...elements] };
}
}
function readMarkdownSpanPlain<Slot extends string>(
chars: string[],
): Result<MarkdownSpan<Slot>[], 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 ? [{ 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

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

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

@ -1,91 +0,0 @@
import {
string,
markdown,
record,
type InferLocaleFromConfig,
type LocaleConfig,
type ConfigString,
} from "@/new-i18n-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: {},
features: { bold: true, italic: true, link: true, slots: {} },
}),
subtext: markdown({
placeables: {},
features: { bold: true, italic: true, link: true, slots: {} },
}),
},
section: {
header: string({ placeables: { ruleNumber: { type: "number" } } }),
body: markdown({
placeables: {},
features: {
bold: true,
header: true,
italic: true,
link: true,
slots: {},
},
}),
},
};
export const localeConfig = {
localeName: plainString(),
vilanticLangs: record(["viossa", "wodox"], () => 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,23 +0,0 @@
import type { VilanticId } from "./vilantic";
export interface Greeting {
title: string;
subtitle: string;
author: string;
lang: VilanticId;
}
export const GREETINGS = [
{
title: "BRÅTULA VIOSSA.NET MÅDE",
subtitle: "Hadjiplas per lera para Viossa glossa fu vi",
author: "Jez",
lang: "viossa",
},
{
title: "akka po viossa.net!",
subtitle: "kenomasufobo o gen wi tropos o viosox",
author: "Tetro",
lang: "wodox",
},
] as const satisfies Greeting[];

View file

@ -1,172 +0,0 @@
import { type InferLocaleFromConfig } from "@/new-i18n-lib/config";
import {
bundleToUncompiledLocaleRecord,
loadFluentBundle,
} from "@/new-i18n-lib/setup";
import { localeConfig } from "./config";
import type { Result } from "@/utils/types";
import { useLocalStorage } from "@vueuse/core";
import { computed, type DeepReadonly } from "vue";
import { type } from "arktype";
import enUsFtlSrc from "@/assets/locale/en_US.ftl";
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
import type { FluentBundle } from "@fluent/bundle";
import { compileLocale } from "@/new-i18n-lib/compile";
export const LOCALE_IDS = ["en-US", "vp-VL", "wp-VL"] as const;
export type LocaleId = typeof LocaleId.infer;
export const LocaleId = type.enumerated(...LOCALE_IDS);
export const DEFAULT_LOCALE_ID = "en-US" satisfies LocaleId;
// users could manually edit localStorage to make this value anything, so we need to validate it
const localStorageLocaleId = useLocalStorage<unknown>(
"localeId",
DEFAULT_LOCALE_ID,
);
export const localeId = computed({
get: (): LocaleId => {
const localeIdRes = LocaleId(localStorageLocaleId.value);
if (localeIdRes instanceof type.errors) {
// if invalid LocaleId, reset to default
localStorageLocaleId.value = DEFAULT_LOCALE_ID;
return DEFAULT_LOCALE_ID;
}
// else return user's selection
const localeId = localeIdRes;
return localeId;
},
// custom setter to ensure it is only set to a valid LocaleId by our code
// (since the localStorage ref is typed as `unknown`, it can be set to any value)
set: (id: LocaleId) => {
localStorageLocaleId.value = id;
},
});
export interface Locale extends InferLocaleFromConfig<typeof localeConfig> {}
async function loadLocale(
localeId: LocaleId,
localeFtlSrc: string,
): Promise<Result<FluentBundle, string>> {
const bundleRes = await loadFluentBundle(localeId, localeFtlSrc);
if (bundleRes.type === "err") {
return bundleRes;
}
const bundle = bundleRes.ok;
return { type: "ok", ok: bundle };
}
interface SetupLocaleFallback {
bundle: FluentBundle;
locale: Locale;
}
function setupLocale(
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: [],
});
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> {
// SAFETY: we're just making an immutable view to the type, this isn't dangerous
return value as DeepReadonly<T>;
}
const DEFAULT_LOCALE_BUNDLE = unwrap(await loadLocale("en-US", enUsFtlSrc));
const DEFAULT_LOCALE = unwrap(setupLocale(DEFAULT_LOCALE_BUNDLE, undefined));
const doItAllForLocale = async (
localeId: LocaleId,
localeFtlSrc: string,
): Promise<DeepReadonly<Locale>> =>
deepReadonly(
unwrap(
setupLocale(unwrap(await loadLocale(localeId, localeFtlSrc)), {
bundle: DEFAULT_LOCALE_BUNDLE,
locale: DEFAULT_LOCALE,
}),
),
);
const [vpVl, wpVl] = await Promise.all([
doItAllForLocale("vp-VL", vpVlFtlSrc),
doItAllForLocale("wp-VL", wpVlFtlSrc),
]);
const localeIdToLocale = {
"en-US": deepReadonly(DEFAULT_LOCALE),
"vp-VL": vpVl,
"wp-VL": wpVl,
} as const satisfies Record<LocaleId, DeepReadonly<Locale>>;
export interface UseLocaleOptions {
locale?: LocaleId;
}
export const useLocale = (opt: UseLocaleOptions = {}) =>
computed<DeepReadonly<Locale>>(() => {
const localLocaleId = opt.locale ?? localeId.value;
return localeIdToLocale[localLocaleId];
});

View file

@ -1,9 +0,0 @@
import viossaFlag from "@/assets/flag_vp.webp";
import wodoxFlag from "@/assets/flag_wp.webp";
export type VilanticId = "viossa" | "wodox";
export const VILANTIC_ID_TO_FLAG = {
viossa: viossaFlag,
wodox: wodoxFlag,
} as const satisfies Record<VilanticId, string>;

View file

@ -1,49 +0,0 @@
<script setup lang="ts">
import DiscordRuleOverview from "@/components/molecules/DiscordRuleOverview.vue";
import DiscordRuleSection from "@/components/molecules/DiscordRuleSection.vue";
import { useLocale } from "@/new-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

@ -1,81 +0,0 @@
<script setup lang="ts">
import HomeSectionWrapper from "@/components/molecules/HomeSectionWrapper.vue";
import { GREETINGS, type Greeting } from "@/new-i18n/greeting";
import { VILANTIC_ID_TO_FLAG } from "@/new-i18n/vilantic";
import { randomElement } from "@/utils/random";
import { computed } from "vue";
import flakkaImg from "@/assets/flakka.png";
import { useLocale, type Locale } from "@/new-i18n";
import type * as i18n from "@/new-i18n/config";
interface SectionConfig {
id: keyof 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,25 +0,0 @@
<script setup lang="ts">
import { useLocale } from "@/new-i18n";
const locale = useLocale();
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.kotoba.title() }}</h1>
</section>
<section class="section container">
<div class="notification is-info block">
<p>{{ locale.kotoba.searchHelp() }}</p>
</div>
<div class="block is-flex is-flex-direction-row is-gap-2">
<input class="input" type="text" />
</div>
<div class="container"></div>
</section>
</div>
</template>

View file

@ -1,101 +0,0 @@
<script setup lang="ts">
import LearningResourceWrapper, {
type ResourceButton,
} from "@/components/molecules/LearningResourceWrapper.vue";
import { useLocale, type Locale } from "@/new-i18n";
import type * as i18n from "@/new-i18n/config";
import { ignore } from "@/utils/ignore";
import { computed } from "vue";
import discordImg from "@/assets/discord.png";
interface ResourceConfig {
id: keyof 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

@ -1,10 +0,0 @@
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,12 +0,0 @@
import axios from "axios";
import SERVER_URL from "./web.service"
import type { SearchResult } from "@repo/common/dto";
export default class KotobaService {
public static search(search_term:string){
return axios.get<SearchResult>(`${SERVER_URL}/search`,
{params:{ search_term }});
}
}

View file

@ -1,4 +0,0 @@
/* Establish constants here */
export default class WebConstants {
static SERVER_URL: string = "http://localhost:1225"
}

View file

@ -1,5 +0,0 @@
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}

View file

@ -1,26 +0,0 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// 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-routes' {
import type {
RouteRecordInfo,
ParamValue,
ParamValueOneOrMore,
ParamValueZeroOrMore,
ParamValueZeroOrOne,
} from 'vue-router'
/**
* Route name map generated by unplugin-vue-router
*/
export interface RouteNamedMap {
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
'/discord/rules': RouteRecordInfo<'/discord/rules', '/discord/rules', Record<never, never>, Record<never, never>>,
'/kotoba': RouteRecordInfo<'/kotoba', '/kotoba', Record<never, never>, Record<never, never>>,
'/resources': RouteRecordInfo<'/resources', '/resources', Record<never, never>, Record<never, never>>,
}
}

View file

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

View file

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

View file

@ -1,13 +0,0 @@
export function randomMaybeElement<Elements extends unknown[]>(
elements: Elements,
): Elements[number] | undefined {
const index = Math.floor(Math.random() * elements.length);
return elements[index];
}
export function randomElement<Elements extends [unknown, ...unknown[]]>(
elements: Elements,
): Elements[number] {
// SAFETY: because there is always at least one element, undefined will never be returned
return randomMaybeElement(elements) as Elements[number];
}

View file

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

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

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

@ -1,2 +0,0 @@
/// <reference types="vite/client" />
/// <reference types="unplugin-vue-router/client" />

View file

@ -1,36 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"noUncheckedIndexedAccess": true,
"module": "esnext",
"moduleResolution": "bundler",
"target": "esnext",
"lib": [
"ESNext",
"DOM",
],
"rootDir": "src",
"paths": {
"@/*": [
"./src/*"
]
},
},
"vueCompilerOptions": {
"strictTemplates": true,
},
"include": [
"src",
],
"exclude": [
"./eslint.config.js"
]
}

View file

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

View file

@ -1,28 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": [
"ES2023"
],
"module": "NodeNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"esModuleInterop": true,
},
"include": [
"vite.config.ts"
]
}

View file

@ -1,11 +0,0 @@
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: [vueRouter({ root: "src", routesFolder: "pages" }), vue({})],
resolve: { alias: { "@": path.resolve(import.meta.dirname, "src") } },
server: { port: 1224 },
assetsInclude: ["**/*.ftl"],
});

36
pnpm-lock.yaml generated
View file

@ -23,6 +23,9 @@ importers:
'@repo/common':
specifier: workspace:*
version: link:../../libs/common
cors:
specifier: ^2.8.6
version: 2.8.6
csv-parser:
specifier: ^3.2.0
version: 3.2.0
@ -54,6 +57,9 @@ importers:
'@total-typescript/ts-reset':
specifier: ^0.6.1
version: 0.6.1
'@types/cors':
specifier: ^2.8.19
version: 2.8.19
'@types/express':
specifier: ^5.0.3
version: 5.0.3
@ -789,6 +795,9 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/cors@2.8.19':
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@ -1231,6 +1240,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cors@2.8.6:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@ -1635,16 +1648,18 @@ packages:
glob@10.4.5:
resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@11.0.2:
resolution: {integrity: sha512-YT7U7Vye+t5fZ/QMkBFrTJ7ZQxInIUjwyAjVj84CYXqgBdv30MFUPGnBR6sQaVq6Is15wYJUsnzTuWaGRBhBAQ==}
engines: {node: 20 || >=22}
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
hasBin: true
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
@ -2149,6 +2164,10 @@ packages:
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
engines: {node: '>= 0.4'}
@ -2257,6 +2276,7 @@ packages:
prebuild-install@7.1.3:
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
engines: {node: '>=10'}
deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available.
hasBin: true
prelude-ls@1.2.1:
@ -2538,7 +2558,7 @@ packages:
tar@6.2.1:
resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
engines: {node: '>=10'}
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me
deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
tar@7.4.3:
resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==}
@ -2746,6 +2766,7 @@ packages:
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
vary@1.1.2:
@ -3375,6 +3396,10 @@ snapshots:
dependencies:
'@types/node': 22.15.31
'@types/cors@2.8.19':
dependencies:
'@types/node': 22.15.31
'@types/estree@1.0.7': {}
'@types/estree@1.0.8': {}
@ -3908,6 +3933,11 @@ snapshots:
cookie@0.7.2: {}
cors@2.8.6:
dependencies:
object-assign: 4.1.1
vary: 1.1.2
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@ -4877,6 +4907,8 @@ snapshots:
dependencies:
boolbase: 1.0.0
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
on-finished@2.4.1: