From e5fc20920394ec9143359dfe1e9b84517e4d5ae8 Mon Sep 17 00:00:00 2001 From: topsinoty Date: Sat, 4 Jul 2026 15:55:05 +0300 Subject: [PATCH] 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. --- index.html | 2 +- public/_data/workshops.yml | 9 ++---- src/lib/i18n.js | 25 +++++++++++++---- src/lib/index.js | 2 +- src/lib/locales/en/Helpdesk.json | 18 ++++++++++-- src/lib/locales/en/Workshops.json | 2 +- src/lib/locales/est/Helpdesk.json | 18 ++++++++++-- src/lib/locales/est/Workshops.json | 2 +- src/lib/router/Router.vue | 29 ++++++++++++------- src/lib/router/router.js | 45 ++++++++++++++++-------------- src/lib/yaml.js | 18 ++++++++++++ src/routes/Calendar.vue | 19 +++++++++++-- src/routes/Helpdesk.vue | 38 +++++++++++++++++-------- src/routes/Home.vue | 6 ++-- src/routes/Managment.vue | 13 ++++----- src/routes/Mentors.vue | 6 ++-- src/routes/OurWork.vue | 10 +++---- src/routes/Student.vue | 10 ++----- src/routes/Workshops.vue | 23 ++++++++++----- 19 files changed, 189 insertions(+), 106 deletions(-) create mode 100644 src/lib/yaml.js diff --git a/index.html b/index.html index 4826812..e78589a 100644 --- a/index.html +++ b/index.html @@ -1,5 +1,5 @@ - + diff --git a/public/_data/workshops.yml b/public/_data/workshops.yml index f394a77..c1a9977 100644 --- a/public/_data/workshops.yml +++ b/public/_data/workshops.yml @@ -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 - diff --git a/src/lib/i18n.js b/src/lib/i18n.js index 5a91d73..e3af82a 100644 --- a/src/lib/i18n.js +++ b/src/lib/i18n.js @@ -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); diff --git a/src/lib/index.js b/src/lib/index.js index bb08385..0cd4744 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -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'; diff --git a/src/lib/locales/en/Helpdesk.json b/src/lib/locales/en/Helpdesk.json index 5c1ad23..081a1e6 100644 --- a/src/lib/locales/en/Helpdesk.json +++ b/src/lib/locales/en/Helpdesk.json @@ -23,16 +23,28 @@ "quantity": "Quantity/Time", "price": "Price" }, - "note": "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!" + "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": "Monday – Friday 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 buses 11, 37, 83, 91 from Keemia stop or buses 10, 27, 33, 36, 45 from Tehnikaülikool stop.", + "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" } } diff --git a/src/lib/locales/en/Workshops.json b/src/lib/locales/en/Workshops.json index 9b080b2..412494e 100644 --- a/src/lib/locales/en/Workshops.json +++ b/src/lib/locales/en/Workshops.json @@ -2,7 +2,7 @@ "hero": { "label": "Our work", "title": "Workshops", - "titleHighlight": "& courses.", + "titleHighlight": "& courses.", "subtitle": "A selection of workshops conducted by our members over the years." }, "details": { diff --git a/src/lib/locales/est/Helpdesk.json b/src/lib/locales/est/Helpdesk.json index 164ff6b..7d5e8dc 100644 --- a/src/lib/locales/est/Helpdesk.json +++ b/src/lib/locales/est/Helpdesk.json @@ -23,16 +23,28 @@ "quantity": "Kogus/Aeg", "price": "Hind" }, - "note": "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!" + "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": "Esmaspäevast – Reedeni 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 11, 37, 83, 91 bussiga Keemia peatusest või Tehnikaülikool peatusest 10, 27, 33, 36, 45 bussiga.", + "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" } } diff --git a/src/lib/locales/est/Workshops.json b/src/lib/locales/est/Workshops.json index 7f4dd9f..1e1b9f8 100644 --- a/src/lib/locales/est/Workshops.json +++ b/src/lib/locales/est/Workshops.json @@ -2,7 +2,7 @@ "hero": { "label": "Meie tehtud", "title": "Koolitused", - "titleHighlight": "& töötoad.", + "titleHighlight": "& töötoad.", "subtitle": "Valik koolitusi, mida meie liikmed on aastate jooksul läbi viinud." }, "details": { diff --git a/src/lib/router/Router.vue b/src/lib/router/Router.vue index 966f3e7..ec1bc7e 100644 --- a/src/lib/router/Router.vue +++ b/src/lib/router/Router.vue @@ -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(() => { diff --git a/src/lib/router/router.js b/src/lib/router/router.js index a4dc64f..209da11 100644 --- a/src/lib/router/router.js +++ b/src/lib/router/router.js @@ -9,29 +9,34 @@ import { switchLang } from '../i18n.js'; */ export function navigate(path, options = {}) { const { external = false } = 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); } } @@ -78,4 +81,4 @@ export function getPath() { export function getQuery() { const params = new URLSearchParams(window.location.search); return Object.fromEntries(params.entries()); -} \ No newline at end of file +} diff --git a/src/lib/yaml.js b/src/lib/yaml.js new file mode 100644 index 0000000..dab8867 --- /dev/null +++ b/src/lib/yaml.js @@ -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; + } +} diff --git a/src/routes/Calendar.vue b/src/routes/Calendar.vue index 0b678dd..34887d2 100644 --- a/src/routes/Calendar.vue +++ b/src/routes/Calendar.vue @@ -1,10 +1,23 @@