feat: add i18n - dict, en\ru, state
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
@@ -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: {}
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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: {}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
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();
|
||||
|
||||
onMount(() => initLocale());
|
||||
|
||||
const LANG_LABELS: Record<Locale, string> = { ru: 'RU', en: 'EN' };
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -12,9 +20,22 @@
|
||||
<div class="app">
|
||||
<header>
|
||||
<a href="/" class="brand">easy-png-tools</a>
|
||||
<nav aria-label="Разделы">
|
||||
<a class="nav-link workspace-link" href="/">Рабочая область</a>
|
||||
<a class="nav-link" href="/list-tools">Каталог</a>
|
||||
<nav aria-label={t('header.sectionsAria')}>
|
||||
<a class="nav-link workspace-link" href="/">{t('header.workspace')}</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>
|
||||
</header>
|
||||
|
||||
@@ -23,9 +44,7 @@
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p class="text-caption text-muted">
|
||||
Все операции выполняются локально в вашем браузере — файлы никуда не отправляются.
|
||||
</p>
|
||||
<p class="text-caption text-muted">{t('header.footerNote')}</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user