Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic i18n support #941

Merged
merged 6 commits into from
Jan 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 4 additions & 1 deletion lxl-web/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
interface PageData {
locale: import('$lib/i18n/locales').LocaleCode;
t: Awaited<ReturnType<typeof import('$lib/i18n').getTranslator>>;
}
// interface Platform {}
}
}
Expand Down
2 changes: 1 addition & 1 deletion lxl-web/src/app.html
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
Expand Down
18 changes: 18 additions & 0 deletions lxl-web/src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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)
});
};
34 changes: 34 additions & 0 deletions lxl-web/src/lib/components/LangPicker.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { page } from '$app/stores';
import { Locales, defaultLocale } from '$lib/i18n/locales';

function mapLocales(currentLocale: string) {
return Object.keys(Locales).map((locale) => {
// remove any existing lang param and add the new one (if needed)
const arr = $page.url.pathname.split('/').filter((p) => !!p && p !== currentLocale);
if (locale !== defaultLocale) {
arr.unshift(locale);
}
const url = `/${arr.join('/')}${$page.url.search}`;

return { value: locale, name: Locales[locale], url };
});
}

$: localesObj = mapLocales($page.data.locale);
</script>

<nav>
<ul class="flex">
{#each localesObj as locale}
<li class="m-1">
{#if locale.value === $page.data.locale}
<span class="font-bold">{locale.name}</span>
{:else}
<!-- server hook (html lang) needs full page reload -->
<a href={locale.url} data-sveltekit-reload>{locale.name}</a>
{/if}
</li>
{/each}
</ul>
</nav>
2 changes: 1 addition & 1 deletion lxl-web/src/lib/components/Search.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
}
</script>

<form action="/search" on:submit={handleSubmit}>
<form action="search" on:submit={handleSubmit}>
<!-- svelte-ignore a11y-autofocus -->
<input
type="search"
Expand Down
48 changes: 48 additions & 0 deletions lxl-web/src/lib/i18n/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { dev } from '$app/environment';
import { interpolate } from './interpolate';
import { defaultLocale, type LocaleCode } from './locales';
import sv from './locales/sv.js';

// always import default translation?
const loadedTranslations: Record<string, typeof sv> = {
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;
};
}
10 changes: 10 additions & 0 deletions lxl-web/src/lib/i18n/interpolate.test.ts
Original file line number Diff line number Diff line change
@@ -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!');
});
});
8 changes: 8 additions & 0 deletions lxl-web/src/lib/i18n/interpolate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const placeholder = /{(.*?)}/g;

export function interpolate(template: string, values?: Record<string, string>) {
if (!values) {
return template;
}
return template.replace(placeholder, (match, key) => values[key] || match);
}
14 changes: 14 additions & 0 deletions lxl-web/src/lib/i18n/locales.ts
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 11 additions & 0 deletions lxl-web/src/lib/i18n/locales/en.js
Original file line number Diff line number Diff line change
@@ -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: {}
};
12 changes: 12 additions & 0 deletions lxl-web/src/lib/i18n/locales/sv.js
Original file line number Diff line number Diff line change
@@ -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
}
};
9 changes: 9 additions & 0 deletions lxl-web/src/routes/(app)/+layout.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
17 changes: 13 additions & 4 deletions lxl-web/src/routes/(app)/[[lang=lang]]/+layout.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
<script>
<script lang="ts">
import Search from '$lib/components/Search.svelte';
import LangPicker from '$lib/components/LangPicker.svelte';
import '../../../app.css';

export let data;
</script>

<header>
<a href="/">Libris</a>
<Search />
<svelte:head>
<base href={data.base} />
</svelte:head>
<header class="mx-4 flex">
<div class="flex-1">
<a href={data.base}>Libris</a>
<Search />
</div>
<LangPicker />
</header>
<slot />
11 changes: 10 additions & 1 deletion lxl-web/src/routes/(app)/[[lang=lang]]/+page.svelte
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
<h1>Welcome to Libris(?)</h1>
<script lang="ts">
export let data;
</script>

<h1>
{data.t('home.welcome_text', {
site: 'Libris',
day: `${(() => new Intl.DateTimeFormat(data.locale, { weekday: 'long' }).format(new Date()))()}`
})}
</h1>
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}));

Expand Down
14 changes: 8 additions & 6 deletions lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
$: q = $page.url.searchParams.get('q');
</script>

<h1>Search results for: {q}</h1>
<ul>
{#each data.items as item (item['@id'])}
<li>{item['@id']}</li>
{/each}
</ul>
<div class="m-3">
<h1>{$page.data.t('search.result_info', { q: `${q}` })}</h1>
<ul>
{#each data.items as item (item['@id'])}
<li><a class="underline" href={item.fnurgel}>{item.fnurgel}</a></li>
{/each}
</ul>
</div>
5 changes: 4 additions & 1 deletion lxl-web/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const config = {
preprocess: vitePreprocess(),

kit: {
adapter: adapter()
adapter: adapter(),
paths: {
relative: false
}
}
};

Expand Down