From 8255d330988f9f6f98c51407e6e98613f7306150 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Tue, 25 Aug 2026 05:29:58 +0500 Subject: [PATCH] feat: add i18n - dict, en\ru, state --- web/src/lib/i18n/dict.ts | 52 ++++++++++++++ web/src/lib/i18n/en.ts | 114 ++++++++++++++++++++++++++++++ web/src/lib/i18n/i18n.test.ts | 80 +++++++++++++++++++++ web/src/lib/i18n/locale.svelte.ts | 60 ++++++++++++++++ web/src/lib/i18n/ru.ts | 114 ++++++++++++++++++++++++++++++ web/src/lib/i18n/t.ts | 24 +++++++ web/src/routes/+layout.svelte | 61 ++++++++++++++-- 7 files changed, 499 insertions(+), 6 deletions(-) create mode 100644 web/src/lib/i18n/dict.ts create mode 100644 web/src/lib/i18n/en.ts create mode 100644 web/src/lib/i18n/i18n.test.ts create mode 100644 web/src/lib/i18n/locale.svelte.ts create mode 100644 web/src/lib/i18n/ru.ts create mode 100644 web/src/lib/i18n/t.ts diff --git a/web/src/lib/i18n/dict.ts b/web/src/lib/i18n/dict.ts new file mode 100644 index 0000000..51bc339 --- /dev/null +++ b/web/src/lib/i18n/dict.ts @@ -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 = { + 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; + /** Подписи опций select: paramId -> value -> label. */ + options?: Record>; +}; + +export type HeaderStrings = { + workspace: string; + catalog: string; + sectionsAria: string; + footerNote: string; +}; + +export type Dict = { + header: HeaderStrings; + categories: Record; + home: Record; + catalog: Record; + toolPage: Record; + chain: Record; + sourceCard: Record; + resultCard: Record; + paramsCard: Record; + textInput: Record; + textResult: Record; + download: Record; + infoPanel: Record; + dropZone: Record; + search: Record; + ui: Record; + errors: Record; + tools: Record; +}; diff --git a/web/src/lib/i18n/en.ts b/web/src/lib/i18n/en.ts new file mode 100644 index 0000000..a13df74 --- /dev/null +++ b/web/src/lib/i18n/en.ts @@ -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: {} +}; diff --git a/web/src/lib/i18n/i18n.test.ts b/web/src/lib/i18n/i18n.test.ts new file mode 100644 index 0000000..6623a70 --- /dev/null +++ b/web/src/lib/i18n/i18n.test.ts @@ -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; + +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).onlyRuKey; + vi.unstubAllGlobals(); +}); + +describe('t', () => { + it('возвращает строку по точечному пути активной локали', () => { + expect(t('header.workspace')).toBe('Рабочая область'); + setLocale('en'); + expect(t('header.catalog')).toBe('Catalog'); + }); + + it('фолбэк на базовую локаль, если в активной нет ключа', () => { + (ru.home as Record).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'); + }); +}); diff --git a/web/src/lib/i18n/locale.svelte.ts b/web/src/lib/i18n/locale.svelte.ts new file mode 100644 index 0000000..c06400e --- /dev/null +++ b/web/src/lib/i18n/locale.svelte.ts @@ -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 = { ru, en }; + +let locale = $state(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)[section]; + const b = (base as unknown as Record)[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; + } +} diff --git a/web/src/lib/i18n/ru.ts b/web/src/lib/i18n/ru.ts new file mode 100644 index 0000000..11aa605 --- /dev/null +++ b/web/src/lib/i18n/ru.ts @@ -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: {} +}; diff --git a/web/src/lib/i18n/t.ts b/web/src/lib/i18n/t.ts new file mode 100644 index 0000000..0225c5c --- /dev/null +++ b/web/src/lib/i18n/t.ts @@ -0,0 +1,24 @@ +import { getMergedDict } from './locale.svelte'; + +export function interpolate(template: string, vars?: Record): 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 { + let node: unknown = getMergedDict(); + for (const part of path.split('.')) { + if (node && typeof node === 'object' && part in (node as Record)) { + node = (node as Record)[part]; + } else { + return path; + } + } + return typeof node === 'string' ? interpolate(node, vars) : path; +} diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index c12efac..cee4a47 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -1,8 +1,16 @@ @@ -12,9 +20,22 @@
easy-png-tools -
@@ -23,9 +44,7 @@
@@ -77,6 +96,36 @@ 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 { font-weight: 600; color: var(--accent);