WIP: ktb-static #5

Draft
niko wants to merge 9 commits from ktb-static into main
75 changed files with 2665 additions and 31418 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 = '' //blank for same-origin
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>

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

@ -0,0 +1,390 @@
: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);
}
* {
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;
}
/* Small devices (portrait tablets and large phones, 600px and up) */
@media (max-width: 600px) {
.cards-container {
display: flex;
flex-direction: column;
}
.card {
width: 100%;
}
.sidebar {
display: none;
}
}

View file

@ -19,24 +19,32 @@
"license": "ISC",
"packageManager": "pnpm@10.11.0",
"dependencies": {
"@feathersjs/feathers": "^5.0.6",
"@feathersjs/feathers": "^5.0.49",
"@google-cloud/local-auth": "^3.0.1",
"@repo/common": "workspace:*",
"csv-parser": "^3.2.0",
"express": "^5.1.0",
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"csv-parser": "^3.2.1",
"express": "^5.2.1",
"google-auth-library": "^9.15.1",
"googleapis": "^149.0.0",
"node-fetch": "^3.3.2",
"passport": "^0.7.0",
"passport-http": "^0.3.0",
"reflect-metadata": "^0.2.2",
"sqlite3": "^5.1.7",
"typeorm": "0.3.26",
"zod": "^4.1.8"
"zod": "^4.4.3"
},
"devDependencies": {
"@total-typescript/ts-reset": "^0.6.1",
"@types/express": "^5.0.3",
"@types/node": "^22.5.1",
"tsx": "^4.19.4",
"turbo": "^2.8.0"
"@types/bcrypt": "^6.0.0",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/node": "^22.20.1",
"@types/passport": "^1.0.17",
"@types/passport-http": "^0.3.11",
"tsx": "^4.23.12",
"turbo": "^2.10.11"
}
}

View file

@ -1,6 +1,6 @@
import "reflect-metadata"
import { DataSource } from "typeorm"
import {Lemma, WordForm, Media, Example, Definition, Comment, PartOfSpeech, Lect} from "../db/dbmodel.js"
import {Lemma, WordForm, Media, Example, Definition, Comment, PartOfSpeech, Lect, Role, User} from "../db/dbmodel.js"
const persistent_path = process.env.VI_DB_PERSISTENT_PATH || "./res";
@ -9,7 +9,7 @@ export const appDataSource = new DataSource({
database:`${persistent_path}/dev.sqlite`,
synchronize: true,
logging: false,
entities: [Lemma, WordForm, Example, Media, Definition, Comment, PartOfSpeech, Lect],
entities: [Lemma, WordForm, Example, Media, Definition, Comment, PartOfSpeech, Lect, Role, User],
migrations: [],
subscribers: [],
})

View file

@ -10,13 +10,16 @@ import {
ManyToMany,
JoinTable,
} from "typeorm";
import * as bcrypt from 'bcrypt';
@Entity()
export class Lemma extends BaseEntity {
@PrimaryColumn({ type: "text" })
lemma_name: string;
@OneToMany(() => WordForm, (word_form) => word_form.lemma, { cascade: true})
@OneToMany(() => WordForm, (word_form) => word_form.lemma, {
cascade: true,
})
word_forms: WordForm[];
@OneToMany(() => Example, (example) => example.lemma)
@ -114,5 +117,44 @@ export class PartOfSpeech extends BaseEntity {
long_form: string;
@Column({ nullable: false, unique: true, type: "text" })
short_form: string;
short_form: string;
}
@Entity()
export class User extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
user_id: string;
@Column({ type: "text", nullable: false })
user_name: string;
@Column({ type: "text", nullable: false })
password_hash: string;
@ManyToMany(() => Role, (role) => role.users, { eager: true })
@JoinTable()
roles: Role[];
setPassword(plainPassword: string) {
this.password_hash = bcrypt.hashSync(plainPassword, 10);
}
checkPassword(plainPassword: string) {
return bcrypt.compareSync(plainPassword, this.password_hash);
}
}
@Entity()
export class Role extends BaseEntity {
@PrimaryGeneratedColumn("uuid")
role_id: string;
@Column({ type: "text", nullable: false })
role_name: string;
@ManyToMany(() => User, (user) => user.roles)
@JoinTable()
users: User[];
}

View file

@ -1,17 +1,20 @@
import "reflect-metadata";
import { SAMPLE } from "@repo/common/sample";
import path from "path"
import { fileURLToPath } from 'url';
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, Role, User } from "./db/dbmodel.js";
import "@total-typescript/ts-reset";
import {
Like, In
} from "typeorm";
import { Like, In } from "typeorm";
import { BasicStrategy } from "passport-http";
import passport from "passport";
const RELOAD_SHEET_ON_START = false;
const SOURCE_FILE = 'res/sample.tsv'
const SOURCE_FILE = "res/sample.tsv";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
appDataSource
.initialize()
@ -21,48 +24,127 @@ appDataSource
if (RELOAD_SHEET_ON_START) {
await loadSheet();
}
await ensureDefaultRoles();
})
.catch((error) => console.log(error));
async function ensureDefaultRoles(){
const ADMIN_NAME = "admin";
const EDITOR_NAME = "editor";
const READER_NAME = "reader";
let admin_role: Role;
let editor_role: Role;
let reader_role: Role;
// Initialize roles if they don't exist yet
try {
[admin_role, editor_role, reader_role] = await Promise.all([
Role.findOneBy({ role_name: ADMIN_NAME }).then(r => r || Role.save({ role_name: ADMIN_NAME })),
Role.findOneBy({ role_name: EDITOR_NAME }).then(r => r || Role.save({ role_name: EDITOR_NAME })),
Role.findOneBy({ role_name: READER_NAME }).then(r => r || Role.save({ role_name: READER_NAME })),
]);
} catch (error) {
console.error("Role initialization failed:", error);
}
// Initialize sample users
let admin_user: User;
let editor_user: User;
let reader_user: User;
try {
[admin_user, editor_user, reader_user] = await Promise.all([
User.findOne({ where: { user_name: ADMIN_NAME } })
.then(u =>
u || User.save(generate_user(ADMIN_NAME, ADMIN_NAME, [admin_role]))),
User.findOne({ where: { user_name: EDITOR_NAME } })
.then(u =>
u || User.save(generate_user(EDITOR_NAME, EDITOR_NAME, [editor_role]))),
User.findOne({ where: { user_name: READER_NAME } })
.then(u =>
u || User.save(generate_user(READER_NAME, READER_NAME, [reader_role]))),
]);
} catch (error) {
console.error("User initialization failed:", error);
}
}
function generate_user(user_name: string, password: string, roles:Role[]): User{
let user = new User();
user.user_name = user_name;
user.roles = roles;
user.setPassword(password);
return user;
}
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);
console.info(path.join(__dirname, '../../ktb-static'));
app.use(express.json(), express.static(path.join(__dirname, '../../ktb-static')));
passport.use(new BasicStrategy(
async function(user_name, password, cb) {
const user = await User.findOneBy({user_name});
if(!user){
return cb(null, false);
}
if(!user.checkPassword(password)){
return cb(null, false);
}
return cb(null, user);
}
));
app.use(passport.initialize());
app.use(passport.session());
app.get("/login",
passport.authenticate('basic', { session: false }),
(_req, res) => {
res.status(200).send();
});
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 +158,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 +534,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 +563,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 +610,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 "./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.2 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,78 +0,0 @@
localeName = "English"
vilanticLangs-viossa = "Viossa"
vilanticLangs-wodox = "Wodoch"
vilanticLangs-minemiaha = "Minemiaha"
navbar-whatIsViossa = "What is Viossa?"
navbar-resources = "Resources"
navbar-kotoba = "Kotoba"
home-sections-whatIsViossa-title = "What is Viossa?"
home-sections-whatIsViossa-body = "Viossa is a community-created artificial pidgin language, created to simulate the formation of natural pidgin languages. Viossa is characterized by its lack of standardization, with each speaker developing a personal idiolect. Spelling and pronunciation can vary greatly, and serve as a form of personal self-expression. Viossa is learnt and taught entirely by immersion — translation is prohibited while learning."
home-sections-historyOfViossa-title = "History of Viossa"
home-sections-historyOfViossa-body = "Viossa began as a Skype group in 2014, created by members of the r/conlangs community on Reddit, as an experiment to simulate the formation of a pidgin language. Pidgins are simplified languages resulting from contact between populations with no shared common language. Unlike most pidgins, which usually have two to three contributor languages, Viossa comes from many diverse languages. This is because people from all around the world helped to contribute to Viossa's vocabulary."
home-sections-community-title = "Community"
home-sections-community-body = "The Viossa community is rich and colourful, drawing from many global traditions due to its worldwide online membership. Since the teaching culture puts an emphasis on linguistic immersion, and discourages prescriptivism, the culture of Viossa is as diverse and varied as the language and the people who speak it. For many, their personal dialect is a key form of identity and expression. The fluid nature of Viossa and lack of defined meanings makes Viossa popular for creative purposes, such as poetry and songwriting."
home-images-viossaFlag-alt = "Flag of the Viossa Language"
resources-title = "Learning Resources"
resources-resources-discord-title = "Discord Server"
resources-resources-discord-subtitle = "This is where most of the action happens! Hop on in!"
resources-resources-discord-desc = "Viossa Diskordserver (VDS) was founded in 2016, as the successor to the original Viossa chat on Skype, since then it has grown to have over 6,000 members. Via the buttons, please read the rules and then join the server!"
resources-resources-discord-buttons-join-label = "Join"
resources-resources-discord-buttons-rules-label = "Rules"
resources-images-discordLogo-alt = "Discord logo"
kotoba-title = "Tropos-agnostic search"
kotoba-searchHelp = "To searcn tropos-agnostically, enter a term below."
discord-rulesPage-title = "Discord Server Rules"
discord-rulesPage-overview-title = "Overview"
discord-rulesPage-overview-help = "Click any rule to see details."
discord-rulesPage-rules-noTranslation-overview-text = md "No translation! Do not translate to/from Viossa on the server, except the big four translatables (you can learn in hard mode without them!)"
discord-rulesPage-rules-noTranslation-overview-subtext = md --
discord-rulesPage-rules-noTranslation-section-header = "Rule { $ruleNumber }: No translation"
discord-rulesPage-rules-noTranslation-section-body = md
"Translation is not how we learn and teach Viossa. Instead, we teach using pictures, diagrams, video calls, and other aids to couple words to meaning."
"On the Viossa Diskordserver, you are allowed to translate the following four words. If you want an extra challenge, don't unspoiler the text:"
"*TODO - big 4*"
"Outside of the teaching-learning cycle, we also make an exception for artistic translations (such as those of songs, books, or poems), as well as for academic translations (such as for a formal research paper). In both cases, this exception is dependent on translations of either class appearing in the appropriate place. If you're not sure where that is, please ask."
"Additionally, please don't attempt to derive or share translation-based learning materials on-server, or poach members for such a purpose."
discord-rulesPage-rules-lfsv-overview-text = md "If it's understood, it's Viossa."
discord-rulesPage-rules-lfsv-overview-subtext = md --
discord-rulesPage-rules-lfsv-section-header = "Rule { $ruleNumber }: If it's understood, it's Viossa"
discord-rulesPage-rules-lfsv-section-body = md
"All that is required to speak Viossa is that other speakers be able to understand you. There is no right or wrong way to speak or write, and no global standard."
"However, Viossa is a collaborative group project: members should strive to make others understand them, and in return make an effort to understand others."
discord-rulesPage-rules-viossaOnlyChats-overview-text = md "The chats in the Viossa Only category are Viossa only."
discord-rulesPage-rules-viossaOnlyChats-overview-subtext = md --
discord-rulesPage-rules-viossaOnlyChats-section-header = "Rule { $ruleNumber }: Viossa-only chats"
discord-rulesPage-rules-viossaOnlyChats-section-body = md
"Chats in the Viossa Only section do not permit English. If you must use English to coach learners on the learning process, go to **#meta** instead."
"This doesn't mean that other channels are English-only, though! Viossa is allowed everywhere."
discord-rulesPage-rules-sfw-overview-text = md "This server is SFW. No sexually explicit, gory, or violent content."
discord-rulesPage-rules-sfw-overview-subtext = md --
discord-rulesPage-rules-sfw-section-header = "Rule { $ruleNumber }: SFW"
discord-rulesPage-rules-sfw-section-body = md
"If a mod does not like what you have posted, they will inform you; see [Rule 6](internal.replace:#rule-6). This is a public Discord server; think before you post."
discord-rulesPage-rules-respectOthers-overview-text = md "Don't use hate speech, and respect each other."
discord-rulesPage-rules-respectOthers-overview-subtext = md --
discord-rulesPage-rules-respectOthers-section-header = "Rule { $ruleNumber }: Respect one another"
discord-rulesPage-rules-respectOthers-section-body = md
"Respect one another. Using slurs or hate speech against others, whether on- or off-server, or advocating for violence are not welcome. This is an LGBTQ+ friendly international community."
discord-rulesPage-rules-respectStaff-overview-text = md "Respect the rulings of the staff (**@Yewald** and **@Yewaldnen**)."
discord-rulesPage-rules-respectStaff-overview-subtext = md --
discord-rulesPage-rules-respectStaff-section-header = "Rule { $ruleNumber }: Respect the staff's rulings"
discord-rulesPage-rules-respectStaff-section-body = md
"The word of staff (the Yewald as well as the Yewaldnen) is final, and they may kick, ban, or mute members or change members' access permissions to make sure this environment stays respectful and puts the Viossa community first."
"Appeals will always be considered, and if you feel that a mod action was inappropriate, you can DM any Yewald or open a ticket with YAGPDB's /tickets open command."
"If you are banned, there will be instructions on how to appeal the ban, however, please take the time to reflect on the ban reason before appealing."
discord-rulesPage-rules-controversialTopics-overview-text = md "Discussion of controversial topics (politics, war, etc.) should be directed to **#polite**, which requires the **@Ike** role to view, which is itself locked behind **@Viossadjin** and **@mellandjin**."
discord-rulesPage-rules-controversialTopics-overview-subtext = md "**#feels-and-advice** is for talking about your feelings openly, but we draw the line at suicidal or violent ideation. These are trains of thought to be brought to a therapist, and are not jokes. Because of their seriousness, they simply don't belong here."
discord-rulesPage-rules-controversialTopics-section-header = "Rule { $ruleNumber }: #polite and ike"
discord-rulesPage-rules-controversialTopics-section-body = md
"Many are life's troubling realities, and vast is our need to discuss them. The ike category is an opt-in set of chats where discussion of heavy, sensitive, or potentially contentious topics is allowed, provided that users are especially respectful of each other during such discussions. By accepting the ike role, you agree to adhere to this rule and encourage others to do the same."
"# Venting vs seeking advice"
"Sometimes you want to let people know that you're dealing with an issue and just be acknowledged, other times you want help in solving a problem. If you are open to one but not the other, it's often a good idea to let people know as part of the discussion so that you can receive the kind of responses you are looking for."
"# Self-harm and Violence"
"While discussing self-harm in general is allowed (with appropriate and clear use of content warnings), this server in itself is not an emergency mental health resource, and is not a substitute for professional help. Asking others for advice in finding support or resources off-server is fine, but asking others to participate in talking you down is inappropriate."
"You should not use this space to:"
"- express intent or desire to harm yourself or others"
"- solicit help in stopping yourself from harming yourself or someone else"
"By crossing these boundaries, please be aware that you are asking members of the server (including moderators and the owner) to perform a role for which they are not trained or equipped. At the moderators' sole discretion, this may not be tolerated and may result in a warning, timeout, removal of the **@ike** role, or removal from the server."
"If you are struggling with thoughts of this nature, but are not immediately in danger, please consider seeking counseling. If you are experiencing an immediate crisis, please call **988** (in the United States), **999** (in the UK), or locate an emergency hotline appropriate for you. A list of resources by country exists here: [](external.new:https://blog.opencounseling.com/suicide-hotlines/)"

View file

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

View file

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

View file

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

View file

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

View file

@ -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,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 "@/i18n";
import type { DeepReadonly } from "vue";
import { isEmptyMarkdown } from "@/vi18n-lib/markdown";
const props = defineProps<{
overview: DeepReadonly<
Value<Locale["discord"]["rulesPage"]["rules"]>["overview"]
>;
ruleNumber: number;
}>();
const subtext = props.overview.subtext();
</script>
<template>
<span>
<SmartLink
covert
:to="{ type: 'internal', internal: { id: `rule-${ruleNumber}` } }"
:style="{ width: 'fit-content', display: 'inline-block' }">
<li :style="{ width: 'fit-content' }">
<MarkdownDisplay
:markdown="overview.text()"
line-class="mb-0" />
<ul v-if="!isEmptyMarkdown(subtext)" class="mt-0 w-fit">
<MarkdownDisplay
tag="li"
:style="{ width: 'fit-content' }"
:markdown="subtext" />
</ul>
</li>
</SmartLink>
</span>
</template>

View file

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

View file

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

View file

@ -1,101 +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",
},
{
title: "VIOSSA.NET VR̄ATULAŢAJO",
subtitle: "Hažilɛ̄ti na viɔssalɛɾa! Viɔssa lɛstɛvr̄ā ɡlɔssa﹐tɛndɔţa!",
author: "Rju",
lang: "viossa",
},
{
title: "bratsatulla na viossa.net made!",
subtitle: "Furalehti vilanta",
author: "Nikomiko",
lang: "viossa",
},
{
title: "Bratula na viossa.net!",
subtitle: "Davi lera vjosa medrio!",
author: "2o3ka",
lang: "viossa",
},
{
title: "ברא־תולהצה viossa.net מדא!",
subtitle: "דבֿי לרה איו הנסו שתוף צויתה נא בֿיוסה",
author: "Visa Chin",
lang: "viossa",
},
{
title: "Bratullatsa viossa.net made!",
subtitle: "Leratsa Viossa au letstehal sztof andra derna",
author: "Visa Chin",
lang: "viossa",
},
{
title: "BRATULA VIOSSA.NET MADE",
subtitle: "Hadжilehti pęr ʋjosalera! Nintendotsa",
author: "Kurokot",
lang: "viossa",
},
{
title: "бrατuλα αδ viossa.net mαᴅε!!",
subtitle: "xαιλε̃τιʋιossα λεrαᴅѥτ! nιnτεnᴅocα nαruγα!",
author: "Orenge",
lang: "viossa",
},
{
title: "Bratulla na viossa.net made!",
subtitle: "Hazsilehti fu viossaklani—yuenttsa na her yo!",
author: "Zsiyo",
lang: "viossa",
},
{
title: "Alú ri viossa.net-ssa!",
subtitle: "Ya vir atuarpik Viossáha druzsai-mais!",
author: "Zsiyo",
lang: "minemiaha",
},
{
title: "GLAUDAI TULANA NA viossa.net ALJIN",
subtitle: "Nintenca au glauca na lerana Viossa",
author: "Delvjin",
lang: "viossa",
},
{
title: "글라우다이 툴라나 나 viossa.net 알진",
subtitle: "닌텐차 아우 글라우차 나 을레라나 삐옷사",
author: "Delvjin",
lang: "viossa",
},
{
title: "Брацатулла на viossa.net",
subtitle: "Фуралегти виланта",
author: "Nikomiko",
lang: "viossa",
},
{
title: "Bratulaca na viossa.net!",
subtitle: "Da lera cui Viossa au da nintendo!",
author: "Luna",
lang: "viossa",
},
] as const satisfies Greeting[];

View file

@ -1,184 +0,0 @@
import { type InferLocaleFromConfig } from "@/vi18n-lib/config";
import {
bundleToUncompiledLocaleRecord,
loadFluentBundle,
} from "@/vi18n-lib/setup";
import { localeConfig } from "./config";
import type { Result } from "@/utils/types";
import { useLocalStorage } from "@vueuse/core";
import { computed, type DeepReadonly } from "vue";
import { type } from "arktype";
import enUsFtlSrc from "@/assets/locale/en_US.ftl";
import vpVlFtlSrc from "@/assets/locale/vp_VL.ftl";
import wpVlFtlSrc from "@/assets/locale/wp_VL.ftl";
import type { FluentBundle } from "@fluent/bundle";
import { compileLocale } from "@/vi18n-lib/compile";
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(
localeId: LocaleId,
localeBundle: FluentBundle,
fallback: SetupLocaleFallback | undefined,
): Result<Locale, string> {
const fallbackBundle = fallback?.bundle;
const fallbackLocale = fallback?.locale;
const maybeFallbackedBundle = (() => {
if (fallbackBundle === undefined) {
return localeBundle;
}
const localeMessageIds = new Set(localeBundle._messages.keys());
const fallbackMessageIds = new Set(fallbackBundle._messages.keys());
const missingMessageIds =
fallbackMessageIds.difference(localeMessageIds);
for (const id of missingMessageIds) {
const fallbackMessage = fallbackBundle._messages.get(id);
if (fallbackMessage) {
localeBundle._messages.set(id, fallbackMessage);
}
}
return localeBundle;
})();
const uncompiledLocaleRecordRes = bundleToUncompiledLocaleRecord(
maybeFallbackedBundle,
);
if (uncompiledLocaleRecordRes.type === "err") {
return uncompiledLocaleRecordRes;
}
const uncompiledLocaleRecord = uncompiledLocaleRecordRes.ok;
const localeRes = compileLocale({
config: localeConfig,
bundle: maybeFallbackedBundle,
uncompiled: uncompiledLocaleRecord,
fallback: fallbackLocale,
messageIdChain: [],
});
const compilationErrorCount = localeRes.errors.length;
if (compilationErrorCount === 0) {
console.log(`[vi18n] Set up locale \`${localeId}\` with no errors!`);
} else {
console.error(
`[vi18n] Set up locale \`${localeId}\` with ${compilationErrorCount} following errors:`,
);
console.error(localeRes.errors);
}
const locale = localeRes.locale;
return { type: "ok", ok: locale };
}
function unwrap<T, E>(result: Result<T, E>): T {
switch (result.type) {
case "ok": {
return result.ok;
}
case "err": {
throw new Error(String(result.err));
}
}
}
function deepReadonly<T>(value: T): DeepReadonly<T> {
// 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_ID, DEFAULT_LOCALE_BUNDLE, undefined),
);
const doItAllForLocale = async (
localeId: LocaleId,
localeFtlSrc: string,
): Promise<DeepReadonly<Locale>> =>
deepReadonly(
unwrap(
setupLocale(
localeId,
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,11 +0,0 @@
import viossaFlag from "@/assets/flag_vp.webp";
import wodoxFlag from "@/assets/flag_wp.webp";
import minemiahaFlag from "@/assets/flag_mi.webp";
export type VilanticId = "viossa" | "wodox" | "minemiaha";
export const VILANTIC_ID_TO_FLAG = {
viossa: viossaFlag,
wodox: wodoxFlag,
minemiaha: minemiahaFlag,
} as const satisfies Record<VilanticId, string>;

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

View file

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

View file

@ -1,25 +0,0 @@
<script setup lang="ts">
import { useLocale } from "@/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 "@/i18n";
import type * as i18n from "@/i18n/config";
import { ignore } from "@/utils/ignore";
import { computed } from "vue";
import discordImg from "@/assets/discord.png";
interface ResourceConfig {
id: keyof Locale["resources"]["resources"];
image?: keyof typeof imagesI18n.value;
}
const RESOURCE_CONFIGS = [
{ id: "discord", image: "discordLogo" },
] as const satisfies ResourceConfig[];
const locale = useLocale();
const pageI18n = computed(() => locale.value.resources);
interface ImageI18n {
src: string;
metadata: i18n.Image;
}
const imagesI18n = computed(() => {
const imagesI18n = pageI18n.value.images;
return {
discordLogo: { src: discordImg, metadata: imagesI18n.discordLogo },
} as const satisfies Record<string, ImageI18n>;
});
const resourcesI18n = computed(() =>
RESOURCE_CONFIGS.map(
({ id, image }: ResourceConfig) =>
({
id,
text: pageI18n.value.resources[id],
image: image && imagesI18n.value[image],
}) as const,
),
);
const computeButtons = (
id: keyof Locale["resources"]["resources"],
): ResourceButton[] => {
// will warn us if a new variant is added that isn't handled, and so we should add a switch
// once we have a switch statement, this won't be needed as that will check for exhaustiveness
ignore<"discord">(id);
const buttons = pageI18n.value.resources[id].buttons;
return [
{
link: {
to: {
type: "external",
external: "https://discord.viossa.net",
},
newTab: true,
},
label: buttons.join.label(),
style: { color: "primary" },
},
{
link: {
to: { type: "internal", internal: { route: "/discord/rules" } },
},
label: buttons.rules.label(),
style: { color: "warning", outlined: true },
},
];
};
</script>
<template>
<div>
<section class="section">
<h1 class="title">{{ locale.resources.title() }}</h1>
</section>
<section class="section container">
<LearningResourceWrapper
v-for="(resourceI18n, index) in resourcesI18n"
:key="index"
:title="resourceI18n.text.title()"
:subtitle="resourceI18n.text.subtitle()"
:desc="resourceI18n.text.desc()"
:image="
resourceI18n.image && {
src: resourceI18n.image.src,
alt: resourceI18n.image.metadata.alt(),
}
"
:buttons="computeButtons(resourceI18n.id)" />
</section>
</div>
</template>

View file

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

View file

@ -1,106 +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;
slots: Slots;
bold?: boolean;
italic?: boolean;
header?: boolean;
link?: boolean;
ulist?: boolean;
}
export interface ConfigPlaceableInfo<
Type extends "string" | "number" = "string" | "number",
> {
type: Type;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface ConfigSlotInfo {}
export function string<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
>(
opt: Omit<ConfigString<Placeables>, typeof configMessageTypeSymbol>,
): ConfigString<Placeables> {
return { ...opt, [configMessageTypeSymbol]: configStringSymbol };
}
export function markdown<
const Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
const Slots extends Partial<Record<string, ConfigSlotInfo>>,
>(
opt: Omit<
ConfigMarkdown<Placeables, Slots>,
typeof configMessageTypeSymbol
>,
): ConfigMarkdown<Placeables, Slots> {
return { ...opt, [configMessageTypeSymbol]: configMarkdownSymbol };
}
export type LocaleConfig = {
[id: string]:
| ConfigString<object>
| ConfigMarkdown<object, object>
| LocaleConfig;
};
type MessageCtx<
Placeables extends Partial<Record<string, ConfigPlaceableInfo>>,
> = {
[K in keyof Placeables]: ResolvedPlaceableType<
PlaceableType<Exclude<Placeables[K], undefined>>
>;
};
type PlaceableType<Var extends ConfigPlaceableInfo> =
Var extends ConfigPlaceableInfo<infer Type> ? Type : never;
type ResolvedPlaceableType<Type extends "string" | "number"> =
Type extends "string" ? string
: Type extends "number" ? number
: never;
export type InferLocaleFromConfig<Config extends LocaleConfig> = {
[K in keyof Config]: Config[K] extends LocaleConfig ?
InferLocaleFromConfig<Config[K]>
: Config[K] extends ConfigString<infer Placeables> ?
object extends MessageCtx<Placeables> ?
() => string
: (ctx: MessageCtx<Placeables>) => string
: Config[K] extends ConfigMarkdown<infer Placeables, object> ?
object extends MessageCtx<Placeables> ?
() => Markdown
: (ctx: MessageCtx<Placeables>) => Markdown
: never;
};
export function record<const Key extends PropertyKey, T>(
keys: readonly Key[],
initializer: () => T,
): Record<Key, T> {
const obj: Partial<Record<Key, T>> = {};
for (const key of keys) {
obj[key] = initializer();
}
// SAFETY: we set all properties from keys array above
return obj as Record<Key, T>;
}

View file

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

View file

@ -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,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"],
});

1209
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff