diff --git a/common/i18n.js b/common/i18n.js new file mode 100644 index 000000000..3d1499e9a --- /dev/null +++ b/common/i18n.js @@ -0,0 +1,4 @@ +export const createTranslator = (dictionary = {}) => (key) => { + return dictionary[key] ?? key; +}; + diff --git a/server/i18n.js b/server/i18n.js new file mode 100644 index 000000000..d63e44253 --- /dev/null +++ b/server/i18n.js @@ -0,0 +1,20 @@ +import {readFileSync as nativeReadFileSync} from 'node:fs'; +import {join} from 'node:path'; +import tryCatch from 'try-catch'; + +export const readTranslations = (lang, baseDir, {readFileSync = nativeReadFileSync} = {}) => { + const paths = [ + join(baseDir, '..', 'json', 'i18n', `${lang}.json`), + join(baseDir, '..', 'json', 'i18n', 'en.json'), + ]; + + for (const dictPath of paths) { + const [error, jsonString] = tryCatch(readFileSync, dictPath, 'utf8'); + + if (!error && jsonString) { + return JSON.parse(jsonString); + } + } + + return {}; +}; diff --git a/server/i18n.spec.js b/server/i18n.spec.js new file mode 100644 index 000000000..146fe893a --- /dev/null +++ b/server/i18n.spec.js @@ -0,0 +1,31 @@ +import {test, stub} from 'supertape'; +import {readTranslations} from './i18n.js'; + +test('cloudcmd: server: i18n: readTranslations: success', (t) => { + const readFileSync = stub().returns(JSON.stringify({ + hello: 'world', + })); + + const result = readTranslations('pl', '/base', {readFileSync}); + const expected = {hello: 'world'}; + + t.deepEqual(result, expected); + t.end(); +}); + +test('cloudcmd: server: i18n: readTranslations: fallback on error', (t) => { + let called = false; + const readFileSync = stub(() => { + if (!called) { + called = true; + throw Error('ENOENT: no such file or directory'); + } + return JSON.stringify({hello: 'fallback'}); + }); + + const result = readTranslations('pl', '/base', {readFileSync}); + const expected = {hello: 'fallback'}; + + t.deepEqual(result, expected); + t.end(); +}); diff --git a/server/route.js b/server/route.js index eb67ccc4c..d30edb921 100644 --- a/server/route.js +++ b/server/route.js @@ -14,9 +14,13 @@ import {contentType} from 'mime-types'; import * as CloudFunc from '#common/cloudfunc'; import root from './root.js'; import prefixer from './prefixer.js'; +import {readTranslations} from './i18n.js'; import Template from './template.js'; import {getColumns} from './columns.js'; import {getThemes} from './theme.js'; +import {readFileSync} from 'node:fs'; +import {join, dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; const require = createRequire(import.meta.url); const {stringify} = JSON; @@ -163,6 +167,10 @@ function indexProcessing(config, options) { const name = config('name'); + const __dirname = dirname(fileURLToPath(import.meta.url)); + const lang = config('lang') || 'en'; + const i18nPack = readTranslations(lang, __dirname); + data = rendy(data, { title: CloudFunc.getTitle({ name, @@ -174,7 +182,12 @@ function indexProcessing(config, options) { themes: getThemes()[config('theme')], }); + const i18nScript = ``; + + data = data.replace('', `${i18nScript}`); + return data; + } function buildIndex(config, html, data) { diff --git a/test-e2e/client/i18n.js b/test-e2e/client/i18n.js new file mode 100644 index 000000000..50ac5285b --- /dev/null +++ b/test-e2e/client/i18n.js @@ -0,0 +1,24 @@ +import {test} from '@cloudcmd/test-e2e'; + +test('should inject i18n into index.html during bootstrap', async ({page}) => { + const i18nPack = await page.evaluate(() => globalThis.i18n); + + const isObject = typeof i18nPack === 'object' && i18nPack !== null; + if (!isObject) + throw Error('i18n is not an object'); +}); + +test('should use injected i18n translations on client-side', async ({page}) => { + await page.evaluate(() => { + globalThis.i18n = { + 'F2 - Rename': 'F2 - Zmień nazwę', + }; + }); + + const translation = await page.evaluate(() => { + return globalThis.i18n['F2 - Rename']; + }); + + if (translation !== 'F2 - Zmień nazwę') + throw Error('Client-side translation fallback failed'); +});