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/html/index.html b/html/index.html index 7f0b02eb9..fab7dad2a 100644 --- a/html/index.html +++ b/html/index.html @@ -19,27 +19,29 @@ {{ themes }} - -
{{ fm }}
-
- - - - - - - - - - - - - -
- - - - + +
{{ fm }}
+
+ + + + + + + + + + + + + +
+ + + + + diff --git a/server/cloudcmd.js b/server/cloudcmd.js index 2e7515205..771e84ea5 100644 --- a/server/cloudcmd.js +++ b/server/cloudcmd.js @@ -192,52 +192,59 @@ function cloudcmdMiddle({modules, config}) { const diff = apart(config, 'diff'); const zip = apart(config, 'zip'); const root = apart(config, 'root'); - + const prefix = config('prefix'); // <-- DODAJ TĘ LINIĘ + const dropboxToken = config('dropboxToken'); + const ponseStatic = ponse.createStatic({ cache, root: DIR_ROOT, }); const dropbox = config('dropbox'); - const dropboxToken = config('dropboxToken'); - + const cutPrefix = (middleware) => (req, res, next) => { + if (prefix && req.url.startsWith(prefix)) { + req.url = req.url.replace(prefix, '') || '/'; + } + middleware(req, res, next); + }; + const funcs = clean([ config('console') && konsole({ online, }), config('terminal') && terminal(config, {}), - edward({ + cutPrefix(edward({ root, online, diff, zip, dropbox, dropboxToken, - }), - dword({ + })), + cutPrefix(dword({ root, online, diff, zip, dropbox, dropboxToken, - }), - qword({ + })), + cutPrefix(qword({ root, online, diff, zip, dropbox, dropboxToken, - }), - deepword({ + })), + cutPrefix(deepword({ root, online, diff, zip, dropbox, dropboxToken, - }), + })), fileop(), nomine(), setUrl, diff --git a/server/i18n.js b/server/i18n.js new file mode 100644 index 000000000..b236fc705 --- /dev/null +++ b/server/i18n.js @@ -0,0 +1,23 @@ +import {readFileSync as nativeReadFileSync} from 'node:fs'; +import {join, dirname} from 'node:path'; +import {fileURLToPath} from 'node:url'; +import tryCatch from 'try-catch'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +export const readTranslations = (lang, {readFileSync = nativeReadFileSync} = {}) => { + const paths = [ + join(__dirname, '..', 'json', 'i18n', `${lang}.json`), + join(__dirname, '..', '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..a9ec0f5d1 --- /dev/null +++ b/server/i18n.spec.js @@ -0,0 +1,32 @@ +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', {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', {readFileSync}); + const expected = {hello: 'fallback'}; + + t.deepEqual(result, expected); + t.end(); +}); + diff --git a/server/route.js b/server/route.js index eb67ccc4c..fb0d9aa41 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); + data = rendy(data, { title: CloudFunc.getTitle({ name, @@ -172,9 +180,11 @@ function indexProcessing(config, options) { config: stringify(config('*')), columns: getColumns()[config('columns')], themes: getThemes()[config('theme')], + i18n: stringify(i18nPack) || '{}', // <-- Przywrócono czysty stringify }); 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'); +});