feat: add i18n - dict, en\ru, state

This commit is contained in:
2026-08-25 05:29:58 +05:00
parent 3c0ffa23bc
commit 8255d33098
7 changed files with 499 additions and 6 deletions
+52
View File
@@ -0,0 +1,52 @@
import type { CategoryId } from '../categories';
export const LOCALES = ['ru', 'en'] as const;
export type Locale = (typeof LOCALES)[number];
export const BASE_LOCALE: Locale = 'ru';
export const LOCALE_TAGS: Record<Locale, string> = {
ru: 'ru-RU',
en: 'en-US'
};
export function isLocale(value: unknown): value is Locale {
return typeof value === 'string' && (LOCALES as readonly string[]).includes(value);
}
export type ToolStrings = {
title: string;
description: string;
/** Подписи параметров по их id. */
params?: Record<string, string>;
/** Подписи опций select: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
};
export type HeaderStrings = {
workspace: string;
catalog: string;
sectionsAria: string;
footerNote: string;
};
export type Dict = {
header: HeaderStrings;
categories: Record<CategoryId, string>;
home: Record<string, string>;
catalog: Record<string, string>;
toolPage: Record<string, string>;
chain: Record<string, string>;
sourceCard: Record<string, string>;
resultCard: Record<string, string>;
paramsCard: Record<string, string>;
textInput: Record<string, string>;
textResult: Record<string, string>;
download: Record<string, string>;
infoPanel: Record<string, string>;
dropZone: Record<string, string>;
search: Record<string, string>;
ui: Record<string, string>;
errors: Record<string, string>;
tools: Record<string, ToolStrings>;
};
+114
View File
@@ -0,0 +1,114 @@
import type { Dict } from './dict';
export const en: Dict = {
header: {
workspace: 'Workspace',
catalog: 'Catalog',
sectionsAria: 'Sections',
footerNote:
'All operations run locally in your browser — your files are never uploaded anywhere.'
},
categories: {
convert: 'Convert',
alpha: 'Transparency',
color: 'Color',
geometry: 'Geometry',
filters: 'Filters',
analyze: 'Analyze',
generate: 'Generate'
},
home: {
defaultTitle: 'easy-png-tools — PNG utilities right in your browser',
heroTitle: 'What do you want to do with the image?',
heroLead: 'Find a tool — everything runs locally in your browser.',
restoreLast: '↩ Restore last: {title}',
changeTool: '← Change tool'
},
catalog: {
pageTitle: 'All tools — easy-png-tools',
metaDescription:
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
heading: 'Tool catalog',
lead: '{count} utilities for working with PNG. Everything runs locally in your browser.'
},
toolPage: {
fallbackTitle: 'Tool',
legendSource: 'Source',
legendSummary: 'Summary',
legendResult: 'Result',
legendParams: 'Parameters',
stepHeading: 'Step {n}',
removeStepAria: 'Remove step',
stepError: 'Step {n} ({title}): {msg}'
},
chain: {
stepLabel: 'Step {n}: {title}',
inputLegend: 'Input',
resultLegend: 'Result',
paramsLegend: 'Parameters',
busyTitle: 'Processing…',
busyHint: 'Running chain step',
removeStepAria: 'Remove step'
},
sourceCard: {
replaceImage: 'Replace image'
},
resultCard: {
emptyTitle: 'The result will appear here',
emptyHint: 'First upload a source image on the left',
processingTitle: 'Processing…',
processingHint: 'The image is being processed, this will take a moment',
recalc: 'Recalculating…',
nextTool: '⛓ Next tool',
breakChain: '✂ Break the chain'
},
paramsCard: {
noParams: 'This tool has no parameters — the result is ready as is.'
},
textInput: {
heading: 'Text',
placeholder: 'Paste data here',
aria: 'Text data',
decode: 'Decode'
},
textResult: {
outputAria: 'Text result',
copied: 'Copied',
copy: 'Copy',
downloadTxt: 'Download .txt'
},
download: {
busy: 'Preparing file…',
file: 'Download .{ext}'
},
infoPanel: {
dimensions: 'Dimensions',
alpha: 'Alpha channel',
alphaYes: 'yes — there are semi-transparent pixels',
alphaNo: 'no',
colorCount: 'Unique colors (RGBA)'
},
dropZone: {
pickDefault: 'Drop an image here or click to choose a file',
overlayDefault: 'Release the file to replace the image'
},
search: {
placeholder: 'Find a tool…',
aria: 'Search tools',
nothingFound: 'Nothing found — try another word.'
},
ui: {
showMask: 'Show mask',
decrease: 'Decrease',
increase: 'Increase',
reset: 'Reset',
pipette: 'Eyedropper'
},
errors: {
noImageRun: 'This tool does not process images',
workerFailed: 'Worker execution failed',
workerUnavailable: 'Worker is unavailable',
notFound: 'Tool not found'
},
tools: {}
};
+80
View File
@@ -0,0 +1,80 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getLocale, initLocale, setLocale } from './locale.svelte';
import { interpolate, t } from './t';
import { ru } from './ru';
type Store = Record<string, string>;
function stubStorage(): { store: Store } {
const store: Store = {};
vi.stubGlobal('localStorage', {
getItem: (k: string) => (k in store ? store[k] : null),
setItem: (k: string, v: string) => {
store[k] = v;
}
});
vi.stubGlobal('window', {});
return { store };
}
beforeEach(() => {
setLocale('ru');
delete (ru.home as Record<string, string>).onlyRuKey;
vi.unstubAllGlobals();
});
describe('t', () => {
it('возвращает строку по точечному пути активной локали', () => {
expect(t('header.workspace')).toBe('Рабочая область');
setLocale('en');
expect(t('header.catalog')).toBe('Catalog');
});
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
setLocale('en');
expect(t('home.onlyRuKey')).toBe('Только русская строка');
});
it('неизвестный путь возвращает сам путь', () => {
expect(t('no.such.key')).toBe('no.such.key');
});
});
describe('interpolate', () => {
it('подставляет переменные в шаблон', () => {
expect(interpolate('Шаг {n} из {total}', { n: 2, total: 5 })).toBe('Шаг 2 из 5');
});
it('оставляет плейсхолдер без переменной как есть', () => {
expect(interpolate('Привет, {name}!', {})).toBe('Привет, {name}!');
});
it('без переменных возвращает строку без изменений', () => {
expect(interpolate('Просто текст')).toBe('Просто текст');
});
});
describe('персист локали', () => {
it('initLocale читает сохранённый выбор', () => {
const { store } = stubStorage();
store['locale'] = 'en';
initLocale();
expect(getLocale()).toBe('en');
});
it('initLocale игнорирует мусор в хранилище', () => {
const { store } = stubStorage();
store['locale'] = 'fr';
initLocale();
expect(getLocale()).toBe('ru');
});
it('setLocale сохраняет выбор в localStorage', () => {
const { store } = stubStorage();
initLocale();
setLocale('en');
expect(store['locale']).toBe('en');
expect(getLocale()).toBe('en');
});
});
+60
View File
@@ -0,0 +1,60 @@
import { BASE_LOCALE, isLocale, type Dict, type Locale } from './dict';
import { ru } from './ru';
import { en } from './en';
const STORAGE_KEY = 'locale';
const DICTS: Record<Locale, Dict> = { ru, en };
let locale = $state<Locale>(BASE_LOCALE);
function storage(): Storage | null {
return typeof localStorage === 'undefined' ? null : localStorage;
}
export function getLocale(): Locale {
return locale;
}
export function getDict(): Dict {
return DICTS[locale];
}
/** Словарь активной локали; для отсутствующих в ней значений — фолбэк на базовую. */
export function getMergedDict(): Dict {
if (locale === BASE_LOCALE) return DICTS[BASE_LOCALE];
const active = DICTS[locale];
const base = DICTS[BASE_LOCALE];
return new Proxy(base, {
get(_target, section: string) {
const a = (active as unknown as Record<string, unknown>)[section];
const b = (base as unknown as Record<string, unknown>)[section];
if (
a && b && typeof a === 'object' && typeof b === 'object'
) {
return { ...(b as object), ...(a as object) };
}
return a ?? b;
}
}) as Dict;
}
export function setLocale(next: Locale): void {
locale = next;
storage()?.setItem(STORAGE_KEY, next);
syncLangAttr();
}
/** Читает сохранённый выбор и синхронизирует атрибут lang. Вызывается на клиенте. */
export function initLocale(): void {
if (typeof window === 'undefined') return;
const saved = storage()?.getItem(STORAGE_KEY);
if (isLocale(saved)) locale = saved;
syncLangAttr();
}
function syncLangAttr(): void {
if (typeof document !== 'undefined') {
document.documentElement.lang = locale;
}
}
+114
View File
@@ -0,0 +1,114 @@
import type { Dict } from './dict';
export const ru: Dict = {
header: {
workspace: 'Рабочая область',
catalog: 'Каталог',
sectionsAria: 'Разделы',
footerNote:
'Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.'
},
categories: {
convert: 'Конвертация',
alpha: 'Прозрачность',
color: 'Цвет',
geometry: 'Геометрия',
filters: 'Фильтры',
analyze: 'Анализ',
generate: 'Генерация'
},
home: {
defaultTitle: 'easy-png-tools — PNG-утилиты прямо в браузере',
heroTitle: 'Что делаем с изображением?',
heroLead: 'Найдите инструмент — все операции выполняются локально в браузере.',
restoreLast: '↩ Вернуть последний: {title}',
changeTool: '← Сменить инструмент'
},
catalog: {
pageTitle: 'Все инструменты — easy-png-tools',
metaDescription:
'Полный каталог PNG-утилит: конвертация, прозрачность, цвет, геометрия, анализ и генерация изображений.',
heading: 'Каталог инструментов',
lead: '{count} утилит для работы с PNG. Все операции выполняются локально в браузере.'
},
toolPage: {
fallbackTitle: 'Инструмент',
legendSource: 'Исходник',
legendSummary: 'Сводка',
legendResult: 'Результат',
legendParams: 'Параметры',
stepHeading: 'Шаг {n}',
removeStepAria: 'Убрать шаг',
stepError: 'Шаг {n} ({title}): {msg}'
},
chain: {
stepLabel: 'Шаг {n}: {title}',
inputLegend: 'Вход',
resultLegend: 'Результат',
paramsLegend: 'Параметры',
busyTitle: 'Обработка…',
busyHint: 'Выполняется шаг цепочки',
removeStepAria: 'Убрать шаг'
},
sourceCard: {
replaceImage: 'Заменить изображение'
},
resultCard: {
emptyTitle: 'Результат появится здесь',
emptyHint: 'Сначала загрузите исходное изображение слева',
processingTitle: 'Обработка…',
processingHint: 'Изображение обрабатывается, это займёт немного времени',
recalc: 'Пересчёт…',
nextTool: '⛓ Следующий инструмент',
breakChain: '✂ Оборвать цепочку'
},
paramsCard: {
noParams: 'У этого инструмента нет параметров — результат уже готов.'
},
textInput: {
heading: 'Текст',
placeholder: 'Вставьте данные сюда',
aria: 'Текстовые данные',
decode: 'Декодировать'
},
textResult: {
outputAria: 'Текстовый результат',
copied: 'Скопировано',
copy: 'Копировать',
downloadTxt: 'Скачать .txt'
},
download: {
busy: 'Готовим файл…',
file: 'Скачать .{ext}'
},
infoPanel: {
dimensions: 'Размеры',
alpha: 'Альфа-канал',
alphaYes: 'есть — есть полупрозрачные пиксели',
alphaNo: 'нет',
colorCount: 'Уникальных цветов (RGBA)'
},
dropZone: {
pickDefault: 'Перетащите изображение сюда или нажмите, чтобы выбрать файл',
overlayDefault: 'Отпустите файл, чтобы заменить изображение'
},
search: {
placeholder: 'Найдите инструмент…',
aria: 'Поиск инструмента',
nothingFound: 'Ничего не найдено — попробуйте другое слово.'
},
ui: {
showMask: 'Показать маску',
decrease: 'Уменьшить',
increase: 'Увеличить',
reset: 'Сбросить',
pipette: 'Пипетка'
},
errors: {
noImageRun: 'Этот инструмент не обрабатывает изображения',
workerFailed: 'Ошибка исполнения в воркере',
workerUnavailable: 'Воркер недоступен',
notFound: 'Инструмент не найден'
},
tools: {}
};
+24
View File
@@ -0,0 +1,24 @@
import { getMergedDict } from './locale.svelte';
export function interpolate(template: string, vars?: Record<string, string | number>): string {
if (!vars) return template;
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
name in vars ? String(vars[name]) : match
);
}
/**
* Перевод по точечному пути вида 'header.workspace' или 'errors.ERR_BAD_HEX'.
* Сначала активная локаль, затем базовая; если ключа нет нигде — возвращается сам путь.
*/
export function t(path: string, vars?: Record<string, string | number>): string {
let node: unknown = getMergedDict();
for (const part of path.split('.')) {
if (node && typeof node === 'object' && part in (node as Record<string, unknown>)) {
node = (node as Record<string, unknown>)[part];
} else {
return path;
}
}
return typeof node === 'string' ? interpolate(node, vars) : path;
}
+55 -6
View File
@@ -1,8 +1,16 @@
<script lang="ts"> <script lang="ts">
import '../app.css'; import '../app.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { onMount } from 'svelte';
import { getLocale, initLocale, setLocale } from '$lib/i18n/locale.svelte';
import { t } from '$lib/i18n/t';
import { LOCALES, type Locale } from '$lib/i18n/dict';
let { children } = $props(); let { children } = $props();
onMount(() => initLocale());
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
</script> </script>
<svelte:head> <svelte:head>
@@ -12,9 +20,22 @@
<div class="app"> <div class="app">
<header> <header>
<a href="/" class="brand">easy-png-tools</a> <a href="/" class="brand">easy-png-tools</a>
<nav aria-label="Разделы"> <nav aria-label={t('header.sectionsAria')}>
<a class="nav-link workspace-link" href="/">Рабочая область</a> <a class="nav-link workspace-link" href="/">{t('header.workspace')}</a>
<a class="nav-link" href="/list-tools">Каталог</a> <a class="nav-link" href="/list-tools">{t('header.catalog')}</a>
<div class="lang-switch" role="group" aria-label="Language / Язык">
{#each LOCALES as l (l)}
<button
type="button"
class="lang-btn"
class:active={getLocale() === l}
aria-pressed={getLocale() === l}
onclick={() => setLocale(l)}
>
{LANG_LABELS[l]}
</button>
{/each}
</div>
</nav> </nav>
</header> </header>
@@ -23,9 +44,7 @@
</main> </main>
<footer> <footer>
<p class="text-caption text-muted"> <p class="text-caption text-muted">{t('header.footerNote')}</p>
Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.
</p>
</footer> </footer>
</div> </div>
@@ -77,6 +96,36 @@
text-decoration: none; text-decoration: none;
} }
.lang-switch {
display: flex;
gap: 2px;
margin-left: var(--space-2);
padding: 2px;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--bg);
}
.lang-btn {
border: none;
background: transparent;
color: var(--text-muted);
font-size: var(--text-s);
font-weight: 600;
padding: 2px 8px;
border-radius: calc(var(--radius-s) - 1px);
cursor: pointer;
}
.lang-btn:hover {
color: var(--accent);
}
.lang-btn.active {
background: var(--accent);
color: var(--bg);
}
.workspace-link { .workspace-link {
font-weight: 600; font-weight: 600;
color: var(--accent); color: var(--accent);