diff --git a/lxl-web/src/app.d.ts b/lxl-web/src/app.d.ts index f59b884c5..e97b185ff 100644 --- a/lxl-web/src/app.d.ts +++ b/lxl-web/src/app.d.ts @@ -4,7 +4,10 @@ declare global { namespace App { // interface Error {} // interface Locals {} - // interface PageData {} + interface PageData { + locale: import('$lib/i18n/locales').LocaleCode; + t: Awaited>; + } // interface Platform {} } } diff --git a/lxl-web/src/app.html b/lxl-web/src/app.html index 77a5ff52c..817dac301 100644 --- a/lxl-web/src/app.html +++ b/lxl-web/src/app.html @@ -1,5 +1,5 @@ - + diff --git a/lxl-web/src/hooks.server.ts b/lxl-web/src/hooks.server.ts new file mode 100644 index 000000000..af8e13933 --- /dev/null +++ b/lxl-web/src/hooks.server.ts @@ -0,0 +1,18 @@ +import { defaultLocale, Locales } from '$lib/i18n/locales'; + +export const handle = async ({ event, resolve }) => { + // set HTML lang + // https://github.com/sveltejs/kit/issues/3091#issuecomment-1112589090 + const path = event.url.pathname; + let lang = defaultLocale; + + Object.keys(Locales).forEach((locale) => { + if (path && (path.startsWith(`/${locale}/`) || path.endsWith(`/${locale}`))) { + lang = locale; + } + }); + + return resolve(event, { + transformPageChunk: ({ html }) => html.replace('%lang%', lang) + }); +}; diff --git a/lxl-web/src/lib/components/LangPicker.svelte b/lxl-web/src/lib/components/LangPicker.svelte new file mode 100644 index 000000000..94319caba --- /dev/null +++ b/lxl-web/src/lib/components/LangPicker.svelte @@ -0,0 +1,34 @@ + + + diff --git a/lxl-web/src/lib/components/Search.svelte b/lxl-web/src/lib/components/Search.svelte index 86f64822d..edcb42e52 100644 --- a/lxl-web/src/lib/components/Search.svelte +++ b/lxl-web/src/lib/components/Search.svelte @@ -26,7 +26,7 @@ } -
+ = { + sv +}; + +export async function getTranslator(locale: LocaleCode) { + if (!loadedTranslations[locale]) { + loadedTranslations[locale] = (await import(`./locales/${locale}.js`)).default; + // add error handling? + } + + return (key: string, values?: { [key: string]: string }): string => { + if (!key.includes('.')) { + // do we require nested keys? + throw new Error('Incorrect i11n key'); + } + const [section, item] = key.split('.') as [string, string]; + + // @ts-expect-error - how to typecheck?? + const localeResult = loadedTranslations[locale][section]?.[item]; + + if (localeResult) { + return interpolate(localeResult, values); + } else { + console.warn(`Missing ${locale} translation for ${key}`); + } + + // @ts-expect-error - how to typecheck?? + const fallbackResult = loadedTranslations[defaultLocale][section][item]; + + if (fallbackResult) { + return interpolate(fallbackResult, values); + } + + const error = `Missing fallback translation for ${key}`; + if (dev) { + throw new Error(error); + } + + console.error(error); + return key; + }; +} diff --git a/lxl-web/src/lib/i18n/interpolate.test.ts b/lxl-web/src/lib/i18n/interpolate.test.ts new file mode 100644 index 000000000..8b8a0f3a6 --- /dev/null +++ b/lxl-web/src/lib/i18n/interpolate.test.ts @@ -0,0 +1,10 @@ +import { describe, it, expect } from 'vitest'; +import { interpolate } from './interpolate'; + +describe('iterpolate', () => { + it('replaces placeholders with corresponding values', () => { + const template = 'Hi {name}, welcome to {site}!'; + const values = { name: 'Kalle', site: 'Libris' }; + expect(interpolate(template, values)).toEqual('Hi Kalle, welcome to Libris!'); + }); +}); diff --git a/lxl-web/src/lib/i18n/interpolate.ts b/lxl-web/src/lib/i18n/interpolate.ts new file mode 100644 index 000000000..5b5120f61 --- /dev/null +++ b/lxl-web/src/lib/i18n/interpolate.ts @@ -0,0 +1,8 @@ +const placeholder = /{(.*?)}/g; + +export function interpolate(template: string, values?: Record) { + if (!values) { + return template; + } + return template.replace(placeholder, (match, key) => values[key] || match); +} diff --git a/lxl-web/src/lib/i18n/locales.ts b/lxl-web/src/lib/i18n/locales.ts new file mode 100644 index 000000000..c3a22a5d7 --- /dev/null +++ b/lxl-web/src/lib/i18n/locales.ts @@ -0,0 +1,14 @@ +export enum Locales { + sv = 'Svenska', + en = 'English' +} + +export type LocaleCode = keyof typeof Locales; +export const defaultLocale = 'sv'; + +export function getSupportedLocale(userLocale: string | undefined): LocaleCode { + const locale = Object.keys(Locales).find((supportedLocale) => { + return userLocale?.includes(supportedLocale); + }) as LocaleCode; + return locale || defaultLocale; +} diff --git a/lxl-web/src/lib/i18n/locales/en.js b/lxl-web/src/lib/i18n/locales/en.js new file mode 100644 index 000000000..f993e4061 --- /dev/null +++ b/lxl-web/src/lib/i18n/locales/en.js @@ -0,0 +1,11 @@ +/** @type {typeof import('./sv.js').default} */ +export default { + home: { + welcome_text: 'Welcome to {site}, today is {day}' + }, + search: { + result_info: 'Search result for: {q}' + }, + errors: {}, + general: {} +}; diff --git a/lxl-web/src/lib/i18n/locales/sv.js b/lxl-web/src/lib/i18n/locales/sv.js new file mode 100644 index 000000000..787e7d08e --- /dev/null +++ b/lxl-web/src/lib/i18n/locales/sv.js @@ -0,0 +1,12 @@ +export default { + home: { + welcome_text: 'Välkommen till {site}, idag är det {day}' + }, + search: { + result_info: 'Sökresultat för: {q}' + }, + errors: {}, + general: { + // shared stuff go here + } +}; diff --git a/lxl-web/src/routes/(app)/+layout.ts b/lxl-web/src/routes/(app)/+layout.ts new file mode 100644 index 000000000..50d97e242 --- /dev/null +++ b/lxl-web/src/routes/(app)/+layout.ts @@ -0,0 +1,9 @@ +import { getTranslator } from '$lib/i18n/index.js'; +import { getSupportedLocale, defaultLocale } from '$lib/i18n/locales'; + +export async function load({ params }) { + const locale = getSupportedLocale(params?.lang); // will use default locale if no lang param + const t = await getTranslator(locale); + const base = locale === defaultLocale ? '/' : `/${locale}/`; + return { locale, t, base }; +} diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte index b8ca15c1f..b2cb14a6b 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte @@ -1,10 +1,19 @@ - -
- Libris - + + + +
+
+ Libris + +
+
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/+page.svelte index 8bd470f97..e4bf49964 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/+page.svelte +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/+page.svelte @@ -1 +1,10 @@ -

Welcome to Libris(?)

+ + +

+ {data.t('home.welcome_text', { + site: 'Libris', + day: `${(() => new Intl.DateTimeFormat(data.locale, { weekday: 'long' }).format(new Date()))()}` + })} +

diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts index 5be3387e0..68f1d906e 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts @@ -11,6 +11,7 @@ export const load = (async ({ fetch, url }) => { const records = await recordsRes.json(); const items = records.items.map((item) => ({ + fnurgel: new URL(item['@id']).pathname.replace('/', ''), '@id': item['@id'] })); diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte index cd59cc5c9..155fb7067 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte @@ -7,9 +7,11 @@ $: q = $page.url.searchParams.get('q'); -

Search results for: {q}

-
    - {#each data.items as item (item['@id'])} -
  • {item['@id']}
  • - {/each} -
+
+

{$page.data.t('search.result_info', { q: `${q}` })}

+
    + {#each data.items as item (item['@id'])} +
  • {item.fnurgel}
  • + {/each} +
+
diff --git a/lxl-web/svelte.config.js b/lxl-web/svelte.config.js index a421ba584..f57d517be 100644 --- a/lxl-web/svelte.config.js +++ b/lxl-web/svelte.config.js @@ -8,7 +8,10 @@ const config = { preprocess: vitePreprocess(), kit: { - adapter: adapter() + adapter: adapter(), + paths: { + relative: false + } } };