fix(router,i18n): harden links, loading, and localized content

Make the custom router follow browser link conventions: resolve clicks from nested elements, preserve query strings and hashes, ignore modified, download, targeted, external, and in-page links, synchronize navigation listeners, and prevent stale lazy imports from replacing the newest route.

Keep language selection authoritative during translated navigation, reject unsupported stored locales, prevent stale translation requests from winning races, and synchronize the document language with standards-based et-EE and en-GB locale identifiers.

Route every YAML-backed page through one loader that checks HTTP responses, validates the parsed data shape against its fallback, and returns safe defaults. Render locale-neutral workshop months with Intl.DateTimeFormat and localize the embedded calendar URL from the active locale.

Replace HTML-bearing Helpdesk and workshop translations with structured plain-text segments rendered through escaped Vue bindings. This preserves emphasis and spacing without v-html, while direct MapLibre markers now build fixed DOM nodes through textContent and clean up safely after asynchronous loading.
This commit is contained in:
topsinoty
2026-07-04 15:55:05 +03:00
parent 13322e11cb
commit e5fc209203
19 changed files with 189 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="et-EE">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

View File

@@ -12,9 +12,7 @@
duration:
en: 3 × 1h 30min
est: 3 × 1h 30min
date:
en: March 2026
est: Märts 2026
date: 2026-03
gallery:
- temp1
- temp2
@@ -31,11 +29,8 @@
duration:
en: 3 × 1h 30min
est: 3 × 1h 30min
date:
en: March 2026
est: Märts 2026
date: 2026-03
gallery:
- temp1
- temp2
- temp3

View File

@@ -1,7 +1,9 @@
import { onScopeDispose, ref, watch } from 'vue';
import { computed, onScopeDispose, ref, watch } from 'vue';
import { getLanguageFromRoute } from '../routes';
const localeModules = import.meta.glob('./locales/*/*.json');
const supportedLanguages = new Set(['est', 'en']);
export const languageLocales = { est: 'et-EE', en: 'en-GB' };
function getInitialLanguage() {
if (typeof window === 'undefined') return 'est';
@@ -12,13 +14,17 @@ function getInitialLanguage() {
return langFromRoute;
}
return localStorage.getItem('language') || 'est';
const savedLanguage = localStorage.getItem('language');
return supportedLanguages.has(savedLanguage) ? savedLanguage : 'est';
}
export const currentLang = ref(getInitialLanguage());
export const currentLocale = computed(() => languageLocales[currentLang.value]);
export const text = ref({});
let translationRequest = 0;
async function loadTranslations(lang) {
const request = ++translationRequest;
try {
const translations = {};
const matchingModules = Object.entries(localeModules).filter(([path]) =>
@@ -26,10 +32,10 @@ async function loadTranslations(lang) {
);
const modules = await Promise.all(matchingModules.map(([, loader]) => loader()));
modules.forEach((module) => Object.assign(translations, module.default || module));
text.value = translations;
if (request === translationRequest) text.value = translations;
} catch (error) {
console.error(`Failed to load translations for ${lang}:`, error);
text.value = {};
if (request === translationRequest) text.value = {};
}
}
@@ -37,14 +43,16 @@ watch(currentLang, loadTranslations, { immediate: true });
export function usePageText(pageName) {
const pageText = ref({});
let pageRequest = 0;
const stop = watch(currentLang, async (lang) => {
const request = ++pageRequest;
try {
const loader = localeModules[`./locales/${lang}/${pageName}.json`];
const module = loader ? await loader() : null;
pageText.value = module?.default || module || {};
if (request === pageRequest) pageText.value = module?.default || module || {};
} catch (error) {
console.error(`Failed to load ${pageName} translations for ${lang}:`, error);
pageText.value = {};
if (request === pageRequest) pageText.value = {};
}
}, { immediate: true });
@@ -53,10 +61,15 @@ export function usePageText(pageName) {
}
export function switchLang(lang) {
if (!supportedLanguages.has(lang)) return;
currentLang.value = lang;
if (typeof window !== 'undefined') localStorage.setItem('language', lang);
}
watch(currentLocale, (locale) => {
if (typeof document !== 'undefined') document.documentElement.lang = locale;
}, { immediate: true });
if (typeof window !== 'undefined') {
window.addEventListener('popstate', () => {
const lang = getLanguageFromRoute(window.location.pathname);

View File

@@ -1,5 +1,5 @@
export { navigate, goBack, reload, getPath, getQuery, switchLanguageRoute } from './router/router.js';
export { default as Router } from './router/Router.vue';
export { currentLang, text, switchLang, usePageText } from './i18n.js';
export { currentLang, currentLocale, languageLocales, text, switchLang, usePageText } from './i18n.js';
export { getLangText } from './langHelpers.js';

View File

@@ -23,16 +23,28 @@
"quantity": "Quantity/Time",
"price": "Price"
},
"note": "Parts are ordered by the client themselves or by agreement with the technician.<br/><br/>If needed, we can come to the student village and Technopol area.<br/><br/>With our student-friendly prices and personal approach, we offer a service you won't be disappointed with!"
"notes": [
"Parts are ordered by the client themselves or by agreement with the technician.",
"If needed, we can come to the student village and Technopol area.",
"With our student-friendly prices and personal approach, we offer a service you won't be disappointed with!"
]
},
"hours": {
"title": "Opening Hours:",
"schedule": "<strong>M</strong>onday <strong>F</strong>riday 14-19*",
"schedule": [
{ "emphasis": "M", "text": "onday " },
{ "emphasis": "F", "text": "riday 14-19*" }
],
"note": "* This is the time period when we are most likely to be found. However, we cannot guarantee presence."
},
"location": {
"title": "Location",
"busInfo": "Accessible via <span class='text-[#E69635]'>buses 11, 37, 83, 91 from Keemia stop</span> or <span class='text-[#E69635]'>buses 10, 27, 33, 36, 45 from Tehnikaülikool stop.</span>",
"busInfo": [
{ "text": "Accessible via " },
{ "text": "buses 11, 37, 83, 91 from Keemia stop", "highlight": true },
{ "text": " or " },
{ "text": "buses 10, 27, 33, 36, 45 from Tehnikaülikool stop.", "highlight": true }
],
"address": "Akadeemia tee 5 dormitory, room 008A"
}
}

View File

@@ -2,7 +2,7 @@
"hero": {
"label": "Our work",
"title": "Workshops",
"titleHighlight": "&amp; courses.",
"titleHighlight": "& courses.",
"subtitle": "A selection of workshops conducted by our members over the years."
},
"details": {

View File

@@ -23,16 +23,28 @@
"quantity": "Kogus/Aeg",
"price": "Hind"
},
"note": "Jupid tellitakse kliendi enda poolt või kokkuleppel tehnikuga.<br/><br/>Vajadusel tuleme üliõpilasküla ja Tehnopoli piires kohale.<br/><br/>Oma tudengisõbralike hindade ja personaalse lähenemisega pakume teenust, milles ei pea pettuma!"
"notes": [
"Jupid tellitakse kliendi enda poolt või kokkuleppel tehnikuga.",
"Vajadusel tuleme üliõpilasküla ja Tehnopoli piires kohale.",
"Oma tudengisõbralike hindade ja personaalse lähenemisega pakume teenust, milles ei pea pettuma!"
]
},
"hours": {
"title": "Lahtiolekuajad:",
"schedule": "<strong>E</strong>smaspäevast <strong>R</strong>eedeni 14-19*",
"schedule": [
{ "emphasis": "E", "text": "smaspäevast " },
{ "emphasis": "R", "text": "eedeni 14-19*" }
],
"note": "* Tegu on ajaperioodiga, mil meid on kõige tõenäolisem tabada. Siiski ei saa garanteerida kohalolekut."
},
"location": {
"title": "Asukoht",
"busInfo": "Saab <span class='text-[#E69635]'>11, 37, 83, 91 bussiga Keemia peatusest</span> või <span class='text-[#E69635]'>Tehnikaülikool peatusest 10, 27, 33, 36, 45 bussiga.</span>",
"busInfo": [
{ "text": "Saab " },
{ "text": "11, 37, 83, 91 bussiga Keemia peatusest", "highlight": true },
{ "text": " või " },
{ "text": "Tehnikaülikool peatusest 10, 27, 33, 36, 45 bussiga.", "highlight": true }
],
"address": "Akadeemia tee 5 ühiselamu, ruum 008A"
}
}

View File

@@ -2,7 +2,7 @@
"hero": {
"label": "Meie tehtud",
"title": "Koolitused",
"titleHighlight": "&amp; töötoad.",
"titleHighlight": "& töötoad.",
"subtitle": "Valik koolitusi, mida meie liikmed on aastate jooksul läbi viinud."
},
"details": {

View File

@@ -4,26 +4,29 @@ import { onBeforeUnmount, onMounted, shallowRef, watch } from 'vue';
const props = defineProps({ routes: { type: Object, required: true } });
const currentPath = shallowRef(window.location.pathname);
const CurrentComponent = shallowRef(null);
let routeRequest = 0;
async function resolveCurrentComponent(path) {
const request = ++routeRequest;
const routeEntry = props.routes[path] || props.routes['/'];
if (typeof routeEntry !== 'function') {
CurrentComponent.value = routeEntry || null;
if (request === routeRequest) CurrentComponent.value = routeEntry || null;
return;
}
try {
const module = await routeEntry();
CurrentComponent.value = module?.default || null;
if (request === routeRequest) CurrentComponent.value = module?.default || null;
} catch {
CurrentComponent.value = null;
if (request === routeRequest) CurrentComponent.value = null;
}
}
function navigate(path) {
window.history.pushState({}, '', path);
function navigate(url) {
window.history.pushState({}, '', `${url.pathname}${url.search}${url.hash}`);
window.scrollTo(0, 0);
currentPath.value = path;
currentPath.value = url.pathname;
window.dispatchEvent(new PopStateEvent('popstate'));
}
const handlePopState = () => {
@@ -31,10 +34,16 @@ const handlePopState = () => {
};
const handleClick = (event) => {
if (event.target.tagName === 'A' && event.target.getAttribute('href')?.startsWith('/')) {
event.preventDefault();
navigate(event.target.getAttribute('href'));
}
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
if (!anchor || anchor.target || anchor.hasAttribute('download')) return;
const url = new URL(anchor.href, window.location.href);
if (url.origin !== window.location.origin || !props.routes[url.pathname]) return;
if (url.pathname === window.location.pathname && url.hash) return;
event.preventDefault();
navigate(url);
};
onMounted(() => {

View File

@@ -13,25 +13,30 @@ export function navigate(path, options = {}) {
if (external) {
window.open(path, '_blank', 'noopener,noreferrer');
} else {
const selectedLang =
localStorage.getItem('language') ||
getLanguageFromRoute(window.location.pathname) ||
'est';
const resolvedPath = getTranslatedRoute(path, selectedLang);
window.history.pushState({}, "", resolvedPath);
window.scrollTo(0, 0);
window.dispatchEvent(new PopStateEvent('popstate'));
// Update language based on new route
const lang = getLanguageFromRoute(resolvedPath);
if (lang) {
switchLang(lang);
const url = new URL(path, window.location.origin);
if (url.origin !== window.location.origin) {
window.location.assign(url.href);
return;
}
const selectedLang =
getLanguageFromRoute(window.location.pathname) ||
localStorage.getItem('language') ||
'est';
url.pathname = getTranslatedRoute(url.pathname, selectedLang);
commitNavigation(`${url.pathname}${url.search}${url.hash}`);
}
}
function commitNavigation(path) {
window.history.pushState({}, '', path);
window.scrollTo(0, 0);
window.dispatchEvent(new PopStateEvent('popstate'));
const lang = getLanguageFromRoute(new URL(path, window.location.origin).pathname);
if (lang) switchLang(lang);
}
/**
* Switch language and navigate to translated route
* @param {string} targetLang - Target language ('est' or 'en')
@@ -40,12 +45,10 @@ export function switchLanguageRoute(targetLang) {
const currentPath = window.location.pathname;
const translatedPath = getTranslatedRoute(currentPath, targetLang);
// Always update the language first
switchLang(targetLang);
// Only navigate if the path is different
if (translatedPath !== currentPath) {
navigate(translatedPath);
commitNavigation(translatedPath);
}
}

18
src/lib/yaml.js Normal file
View File

@@ -0,0 +1,18 @@
import yaml from 'js-yaml';
export async function loadYaml(path, fallback) {
try {
const response = await fetch(path);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const value = yaml.load(await response.text());
const matchesFallback = Array.isArray(fallback)
? Array.isArray(value)
: typeof fallback === 'object' && fallback !== null
? typeof value === 'object' && value !== null && !Array.isArray(value)
: value !== undefined && value !== null;
return matchesFallback ? value : fallback;
} catch (error) {
console.warn(`Failed to load YAML from ${path}:`, error);
return fallback;
}
}

View File

@@ -1,10 +1,23 @@
<script setup>
import { Section, Center } from "../components/index.js";
import { onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
import { computed, onMounted, ref } from 'vue';
import { currentLocale, usePageText } from "../lib/index.js";
const sectionElement = ref(null);
const text = usePageText("Calendar");
const calendarUrl = computed(() => {
const params = new URLSearchParams({
src: 'lapikud@gmail.com',
ctz: 'Europe/Tallinn',
showTitle: '0',
showPrint: '0',
showCalendars: '0',
showTz: '0',
wkst: '2',
hl: currentLocale.value,
});
return `https://www.google.com/calendar/embed?${params}`;
});
onMounted(() => {
sectionElement.value?.scrollIntoView({ behavior: 'smooth', block: 'center' });
@@ -18,7 +31,7 @@
<div class="w-full max-w-4xl">
<iframe
style="border: 0;"
src="https://www.google.com/calendar/embed?src=lapikud%40gmail.com&ctz=Europe/Tallinn&showTitle=0&showPrint=0&showCalendars=0&showTz=0&wkst=2"
:src="calendarUrl"
:title="text.iframeTitle || 'Lapikud Calendar'"
class="w-full"
height="600"

View File

@@ -6,7 +6,7 @@
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { usePageText } from "../lib/index.js";
import { getHeroImageFallback, getHeroImageSrcSet, getRootAssetPath } from "../lib/imageHelpers.js";
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
@@ -42,12 +42,12 @@
const pricingData = ref({ services: [] });
const mapElement = ref(null);
let map;
let disposed = false;
const text = usePageText("Helpdesk");
onMounted(async () => {
const response = await fetch('/_data/hinnakiri.yml');
const yamlText = await response.text();
pricingData.value = yaml.load(yamlText) || { services: [] };
pricingData.value = await loadYaml('/_data/hinnakiri.yml', { services: [] });
if (disposed || !mapElement.value) return;
map = new maplibregl.Map({
container: mapElement.value,
@@ -68,13 +68,21 @@
marker.type = 'button';
marker.className = `helpdesk-marker helpdesk-marker--${type}`;
marker.setAttribute('aria-label', `Get directions to ${label}`);
marker.innerHTML = `<span>${label}</span><b aria-hidden="true">${type === 'bus' ? '●' : '◆'}</b>`;
const markerLabel = document.createElement('span');
markerLabel.textContent = label;
const markerIcon = document.createElement('b');
markerIcon.setAttribute('aria-hidden', 'true');
markerIcon.textContent = type === 'bus' ? '●' : '◆';
marker.replaceChildren(markerLabel, markerIcon);
marker.addEventListener('click', () => openDirections(coordinates[0], coordinates[1], type));
new maplibregl.Marker({ element: marker }).setLngLat(coordinates).addTo(map);
});
});
onBeforeUnmount(() => map?.remove());
onBeforeUnmount(() => {
disposed = true;
map?.remove();
});
</script>
<template>
@@ -146,16 +154,18 @@
</tbody>
</table>
</div>
<p class="pt-4 text-lg font-bold">
<span v-html="text.pricing?.note || ''"></span>
</p>
<div class="pt-4 text-lg font-bold">
<p v-for="note in text.pricing?.notes || []" :key="note" class="mb-4 last:mb-0">{{ note }}</p>
</div>
</div>
<div class="flex flex-col">
<h2 class="text-4xl font-light pb-4">
<span v-html="text.hours?.title || ''"></span>
{{ text.hours?.title || '' }}
<br />
<span v-html="text.hours?.schedule || ''"></span>
<template v-for="part in text.hours?.schedule || []" :key="part.emphasis">
<strong>{{ part.emphasis }}</strong>{{ part.text }}
</template>
</h2>
<p class="text-lg font-light opacity-70 pb-16">
{{ text.hours?.note || '' }}
@@ -164,7 +174,11 @@
<h2 class="text-4xl font-light pb-4">{{ text.location?.title || '' }}</h2>
<div ref="mapElement" class="relative w-full h-96 rounded-lg overflow-hidden"></div>
<p class="pb-4 text-lg">
<span v-html="text.location?.busInfo || ''"></span>
<span
v-for="(part, index) in text.location?.busInfo || []"
:key="index"
:class="part.highlight && 'text-[#E69635]'"
>{{ part.text }}</span>
</p>
<p class="text-lg font-bold">
{{ text.location?.address || '' }}

View File

@@ -4,7 +4,7 @@
import { usePageText, currentLang } from "../lib/index.js";
import { getHeroImagePath, getHeroImageSrcSet, getHeroImageFallback, getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { onMounted, ref } from 'vue';
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import { ArrowRight } from "@lucide/vue";
import { ArrowLeft } from "@lucide/vue";
@@ -14,9 +14,7 @@
const text = usePageText("Home");
onMounted(async () => {
const response = await fetch('/_data/partners.yml');
const yamlText = await response.text();
partners.value = yaml.load(yamlText) || [];
partners.value = await loadYaml('/_data/partners.yml', []);
});
const partnerLogoExtByName = {

View File

@@ -6,7 +6,7 @@
Image,
} from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { Mail } from "@lucide/vue";
@@ -45,13 +45,10 @@
onMounted(async () => {
try {
const currentRes = await fetch('/_data/management.yml');
const currentYaml = await currentRes.text();
currentManagement.value = yaml.load(currentYaml) || [];
const pastRes = await fetch('/_data/past_management.yml');
const pastYaml = await pastRes.text();
pastManagement.value = yaml.load(pastYaml) || [];
[currentManagement.value, pastManagement.value] = await Promise.all([
loadYaml('/_data/management.yml', []),
loadYaml('/_data/past_management.yml', []),
]);
} catch (error) {
console.error('Error loading management data:', error);
loadError.value = true;

View File

@@ -1,7 +1,7 @@
<script setup>
import { Section, Container } from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
@@ -15,9 +15,7 @@
onMounted(async () => {
const response = await fetch("/_data/mentors.yml");
const yamlText = await response.text();
mentors.value = yaml.load(yamlText) || [];
mentors.value = await loadYaml('/_data/mentors.yml', []);
});
function getContactIcon(contactType) {

View File

@@ -1,8 +1,8 @@
<script setup>
import { Section, Grid, Card, Button } from "../components/index.js";
import { computed, onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, getLangText, navigate, usePageText } from "../lib/index.js";
import { getOptimisedImagePath, getOptimisedImageFallback } from "../lib/imageHelpers.js";
import { ExternalLink } from "@lucide/vue";
@@ -11,9 +11,7 @@
const text = usePageText("OurWork");
onMounted(async () => {
const response = await fetch("/_data/ourwork.yml");
const yamlText = await response.text();
const parsed = yaml.load(yamlText) || [];
const parsed = await loadYaml('/_data/ourwork.yml', []);
projects.value = parsed.filter((p) => p?.title);
});
@@ -43,7 +41,7 @@
}
function openCtaContact() {
window.location.href = "/contact";
navigate('/contact');
}
</script>

View File

@@ -5,7 +5,7 @@
Button,
} from "../components/index.js";
import { computed, onMounted, ref } from 'vue';
import yaml from 'js-yaml';
import { loadYaml } from '../lib/yaml.js';
import { usePageText } from "../lib/index.js";
const members = ref({ junior: [], senior: [] });
@@ -13,13 +13,7 @@
const joinFormUrl = "https://pilves.lapikud.ee/apps/forms/s/WXed8sbG2s45GMKGAiXCemgE";
onMounted(async () => {
try {
const response = await fetch('/_data/members.yml');
const yamlText = await response.text();
members.value = yaml.load(yamlText) || { junior: [], senior: [] };
} catch {
members.value = { junior: [], senior: [] };
}
members.value = await loadYaml('/_data/members.yml', { junior: [], senior: [] });
});
const scrollToJoin = () => {

View File

@@ -1,17 +1,15 @@
<script setup>
import { Section, Stack, Image } from "../components/index.js";
import { onMounted, ref } from 'vue';
import yaml from "js-yaml";
import { currentLang, getLangText, usePageText } from "../lib/index.js";
import { loadYaml } from '../lib/yaml.js';
import { currentLang, currentLocale, getLangText, usePageText } from "../lib/index.js";
import { getRootAssetPath } from "../lib/imageHelpers.js";
const workshops = ref([]);
const text = usePageText("Workshops");
onMounted(async () => {
const response = await fetch("/_data/workshops.yml");
const yamlText = await response.text();
const parsed = yaml.load(yamlText) || [];
const parsed = await loadYaml('/_data/workshops.yml', []);
workshops.value = parsed.filter((w) => w?.title);
});
@@ -19,6 +17,17 @@
return getLangText(workshop, field, currentLang.value);
}
function formatDate(value) {
const match = String(value || '').match(/^(\d{4})-(\d{2})$/);
if (!match) return '';
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, 1));
return new Intl.DateTimeFormat(currentLocale.value, {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
}).format(date);
}
function getGallery(workshop) {
return Array.isArray(workshop?.gallery) ? workshop.gallery : [];
}
@@ -55,7 +64,7 @@
<h1 class="font-syne text-5xl md:text-6xl lg:text-7xl font-bold leading-tight mb-4">
{{ text.hero?.title }}
<em class="block text-orange-500 not-italic"
><span v-html="text.hero?.titleHighlight"></span></em
>{{ text.hero?.titleHighlight }}</em
>
</h1>
<p class="text-sm text-gray-400 max-w-sm leading-relaxed font-space-grotesk font-light">
@@ -120,7 +129,7 @@
{{ text.details?.date }}
</span>
<span class="text-sm text-gray-700 font-space-grotesk">
{{ getField(workshop, "date") }}
{{ formatDate(workshop.date) }}
</span>
</div>
</div>