feat: base english language, also fallback to english

This commit is contained in:
2026-08-25 08:32:19 +05:00
parent 2301b0e165
commit 74a18d91a7
8 changed files with 767 additions and 273 deletions
+2 -2
View File
@@ -12,7 +12,7 @@
- Без сегментов языка в URL. Статика пререндерится на русском; переключение на клиенте, выбор живёт в localStorage. hreflang/canonical и вторая копия страниц для поисковиков — не в этой волне.
- Ядро не знает о локалях. Ошибки в core выбрасываются стабильными кодами (например ERR_BAD_HEX); перевод кода в человекочитаемый текст происходит в слое исполнителя/UI.
- Строки реестра живут в словарях, а не инлайн: словарь содержит секцию tools, ключ id инструмента, внутри title, description, labels параметров и label'ы опций select. Реестр хранит только схему параметров и логику run.
- Английский — базовая локаль и фолбэк: строки инструментов в реестре записаны по-английски. Переводы живут в секции tools словарей по ключу id инструмента (title, description, подписи параметров, подписи опций select, тексты-результаты анализаторов); сейчас перевод — русский в ru.ts. Доступ из компонентов только через хелперы toolTitle/toolDescription/paramLabel/optionLabel. Так каждая строка имеет единственный источник: en — реестр, переводы — словари.
## 3. Структура i18n-модуля
@@ -27,7 +27,7 @@
- A. Инфраструктура — модуль состояния, функция перевода с фолбэком и интерполяцией, словари-скелеты со всеми секциями (заполнены header и categories как пилот), переключатель RU/EN в шапке, реактивный lang. Тесты: фолбэк на ru при отсутствии ключа в en, интерполяция, персист локали.
- B. Каркас интерфейса — перенос всех хардкод-строк компонентов и маршрутов в словари: layout (шапка, футер), главная (герой, «вернуть последний», title), каталог, карточки источника/результата/параметров, текстовый вход и результат, «Показать маску», DropZone/DropOverlay, кнопка скачивания (busyText), InfoPanel плюс форматирование чисел через тег локали вместо жёсткого ru-RU, ToolPage (легенды, «Шаг n», ошибки шага), ChainToolBlock, placeholder и пустой результат поиска, страницы инструмента (title, 404). Обёрточные сообщения исполнителя («Ошибка исполнения в воркере») — тоже здесь.
- C. Контент реестра — 69 инструментов: title/description/labels/options переезжают в tools-секцию обоих словарей; компоненты получают строки через хелперы toolTitle/paramLabel. Тест целостности: у каждого инструмента в каждом словаре непустые title/description, полный набор param-ключей и отсутствие лишних ключей.
- C. Контент реестра — переводы 69 инструментов (title/description/labels/options) в tools-секции словаря en; компоненты получают строки через хелперы toolTitle/toolDescription/paramLabel/optionLabel с фолбэком на реестр. Тест целостности: у каждого инструмента есть непустые перевод title/description, полный набор param-ключей и подписи всех опций select; лишние ключи словарей отсутствуют.
- D. Поиск по любому языку — нормализация запроса и полей: нижний регистр, ё→е, снятие диакритики через NFD; скоринг против активной и базовой локали сразу, лучший балл побеждает; сортировка совпадений через Intl.Collator активной локали. Тесты нормализации: ё/е, диакритика, английский запрос при русской локали и наоборот.
- E. Ошибки на кодах — около 40 мест throw в core, registry, pipeline заменяются на коды; исполнитель переводит код в текст активной локали, неизвестные сообщения показываются как есть. Пять тестов, матчащих русские подстроки ошибок (executor, pipeline, registry, io, alpha), переходят на коды — это упрощает ассерты. Русские describe/it в тестах не трогаем.
- F. Итог — полный прогон, смоук чеклист §6, архивация плана.
+5 -3
View File
@@ -3,7 +3,7 @@ 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 BASE_LOCALE: Locale = 'en';
export const LOCALE_TAGS: Record<Locale, string> = {
ru: 'ru-RU',
@@ -15,12 +15,14 @@ export function isLocale(value: unknown): value is Locale {
}
export type ToolStrings = {
title: string;
description: string;
title?: string;
description?: string;
/** Подписи параметров по их id. */
params?: Record<string, string>;
/** Подписи опций select: paramId -> value -> label. */
options?: Record<string, Record<string, string>>;
/** Тексты-результаты текстовых инструментов (analyze): ключ -> строка. */
results?: Record<string, string>;
};
export type HeaderStrings = {
+38 -18
View File
@@ -1,4 +1,4 @@
import type { Dict } from './dict';
import type { Dict } from './dict';
export const en: Dict = {
header: {
@@ -6,7 +6,7 @@ export const en: Dict = {
catalog: 'Catalog',
sectionsAria: 'Sections',
footerNote:
'All operations run locally in your browser your files are never uploaded anywhere.'
'All operations run locally in your browser — your files are never uploaded anywhere.'
},
categories: {
convert: 'Convert',
@@ -18,14 +18,14 @@ export const en: Dict = {
generate: 'Generate'
},
home: {
defaultTitle: 'easy-png-tools PNG utilities right in your browser',
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'
heroLead: 'Find a tool — everything runs locally in your browser.',
restoreLast: '↩ Restore last: {title}',
changeTool: 'в†ђ Change tool'
},
catalog: {
pageTitle: 'All tools easy-png-tools',
pageTitle: 'All tools — easy-png-tools',
metaDescription:
'Full catalog of PNG utilities: convert, transparency, color, geometry, analysis and image generation.',
heading: 'Tool catalog',
@@ -46,7 +46,7 @@ export const en: Dict = {
inputLegend: 'Input',
resultLegend: 'Result',
paramsLegend: 'Parameters',
busyTitle: 'Processing',
busyTitle: 'Processing…',
busyHint: 'Running chain step',
removeStepAria: 'Remove step'
},
@@ -56,14 +56,14 @@ export const en: Dict = {
resultCard: {
emptyTitle: 'The result will appear here',
emptyHint: 'First upload a source image on the left',
processingTitle: 'Processing',
processingTitle: 'Processing…',
processingHint: 'The image is being processed, this will take a moment',
recalc: 'Recalculating',
nextTool: ' Next tool',
breakChain: ' Break the chain'
recalc: 'Recalculating…',
nextTool: 'в›“ Next tool',
breakChain: 'вњ‚ Break the chain'
},
paramsCard: {
noParams: 'This tool has no parameters the result is ready as is.'
noParams: 'This tool has no parameters — the result is ready as is.'
},
textInput: {
heading: 'Text',
@@ -78,13 +78,13 @@ export const en: Dict = {
downloadTxt: 'Download .txt'
},
download: {
busy: 'Preparing file',
busy: 'Preparing file…',
file: 'Download .{ext}'
},
infoPanel: {
dimensions: 'Dimensions',
alpha: 'Alpha channel',
alphaYes: 'yes there are semi-transparent pixels',
alphaYes: 'yes — there are semi-transparent pixels',
alphaNo: 'no',
colorCount: 'Unique colors (RGBA)'
},
@@ -93,9 +93,9 @@ export const en: Dict = {
overlayDefault: 'Release the file to replace the image'
},
search: {
placeholder: 'Find a tool',
placeholder: 'Find a tool…',
aria: 'Search tools',
nothingFound: 'Nothing found try another word.'
nothingFound: 'Nothing found — try another word.'
},
ui: {
showMask: 'Show mask',
@@ -110,5 +110,25 @@ export const en: Dict = {
workerUnavailable: 'Worker is unavailable',
notFound: 'Tool not found'
},
tools: {}
tools: {
'png-is-grayscale': {
results: {
grayscaleYes: 'Yes — all pixels are shades of gray.',
grayscaleNo: 'No — colored pixels were found.'
}
},
'png-is-transparent': {
results: {
transparentYes: 'Yes — there are transparent or semi-transparent pixels.',
transparentNo: 'No — all pixels are fully opaque.'
}
},
'png-orientation': {
results: {
orientationPortrait: 'Portrait — height is greater than width.',
orientationLandscape: 'Landscape — width is greater than height.',
orientationSquare: 'Square — the sides are equal.'
}
}
}
};
+6 -6
View File
@@ -1,7 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { getLocale, initLocale, setLocale } from './locale.svelte';
import { interpolate, t } from './t';
import { ru } from './ru';
import { en } from './en';
type Store = Record<string, string>;
@@ -19,7 +19,7 @@ function stubStorage(): { store: Store } {
beforeEach(() => {
setLocale('ru');
delete (ru.home as Record<string, string>).onlyRuKey;
delete (en.home as Record<string, string>).onlyEnKey;
vi.unstubAllGlobals();
});
@@ -30,10 +30,10 @@ describe('t', () => {
expect(t('header.catalog')).toBe('Catalog');
});
it('фолбэк на базовую локаль, если в активной нет ключа', () => {
(ru.home as Record<string, string>).onlyRuKey = 'Только русская строка';
setLocale('en');
expect(t('home.onlyRuKey')).toBe('Только русская строка');
it('фолбэк на английскую базу, если в активной локали нет ключа', () => {
(en.home as Record<string, string>).onlyEnKey = 'Only English string';
setLocale('ru');
expect(t('home.onlyEnKey')).toBe('Only English string');
});
it('неизвестный путь возвращает сам путь', () => {
+370 -2
View File
@@ -1,4 +1,4 @@
import type { Dict } from './dict';
import type { Dict } from './dict';
export const ru: Dict = {
header: {
@@ -110,5 +110,373 @@ export const ru: Dict = {
workerUnavailable: 'Воркер недоступен',
notFound: 'Инструмент не найден'
},
tools: {}
tools: {
'jpg-to-png': {
title: 'Конвертировать JPG в PNG',
description: 'Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.'
},
'webp-to-png': {
title: 'Конвертировать WebP в PNG',
description: 'Перекодирует WebP-изображение в универсальный PNG.'
},
'gif-to-png': {
title: 'Конвертировать GIF в PNG',
description: 'Достаёт первый кадр GIF-анимации и сохраняет его как PNG.'
},
'bmp-to-png': {
title: 'Конвертировать BMP в PNG',
description: 'Перекодирует BMP в компактный PNG без потерь.'
},
'ico-to-png': {
title: 'Конвертировать ICO в PNG',
description: 'Превращает иконку .ico в обычный PNG нужного размера.'
},
'png-to-bmp': {
title: 'Конвертировать PNG в BMP',
description: 'Сохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.'
},
'png-to-base64': {
title: 'PNG в Base64',
description: 'Кодирует изображение в base64-строку для вставки в код или стили.'
},
'base64-to-png': {
title: 'Base64 в PNG',
description: 'Декодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.'
},
'png-to-data-uri': {
title: 'PNG в Data URI',
description: 'Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.'
},
'data-uri-to-png': {
title: 'Data URI в PNG',
description: 'Декодирует data:image/…;base64,… обратно в файл картинки.'
},
'png-to-hex': {
title: 'PNG в HEX-пиксели',
description: 'Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.'
},
'hex-to-png': {
title: 'HEX-пиксели в PNG',
description: 'Собирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.',
params: { width: 'Ширина изображения' }
},
'resize-png': {
title: 'Изменить размер PNG',
description: 'Масштабирование изображения с билинейной интерполяцией. При сохранении пропорций одна сторона задаёт масштаб, а если указаны обе — изображение вписывается в эти размеры.',
params: { width: 'Ширина (0 — авто)', height: 'Высота (0 — авто)', keepAspect: 'Сохранять пропорции' }
},
'crop-png': {
title: 'Обрезать PNG',
description: 'Вырезает прямоугольную область. Координаты и размеры выходят за границы изображения — область усекается до пересечения с картинкой.',
params: { x: 'X (слева)', y: 'Y (сверху)', width: 'Ширина области', height: 'Высота области' }
},
'rotate-png': {
title: 'Повернуть PNG',
description: 'Поворот на 90°, 180° или 270° по часовой стрелке без потери качества.',
params: { angle: 'Угол поворота' },
options: { angle: { '90': '90° по часовой', '180': '180°', '270': '270° по часовой' } }
},
'flip-png': {
title: 'Отразить PNG',
description: 'Зеркальное отражение по горизонтали или вертикали без потери качества.',
params: { axis: 'Ось отражения' },
options: {
axis: {
horizontal: 'По горизонтали (слева направо)',
vertical: 'По вертикали (сверху вниз)'
}
}
},
'add-padding-png': {
title: 'Добавить поля PNG',
description: 'Расширяет холст во все стороны на выбранное число пикселей.',
params: { padding: 'Поля, px', transparent: 'Прозрачные поля', color: 'Цвет полей' }
},
'add-border-png': {
title: 'Добавить рамку PNG',
description: 'Рисует цветную рамку вокруг изображения выбранной толщины.',
params: { thickness: 'Толщина рамки, px', color: 'Цвет рамки' }
},
'fit-on-background-png': {
title: 'Вписать PNG на фон',
description: 'Помещает изображение по центру полотна заданного размера с прозрачным или цветным фоном.',
params: {
width: 'Ширина полотна',
height: 'Высота полотна',
transparent: 'Прозрачный фон',
color: 'Цвет фона'
}
},
'tile-png': {
title: 'Замостить PNG',
description: 'Повторяет изображение сеткой из выбранного числа столбцов и строк.',
params: { columns: 'Столбцов', rows: 'Строк' }
},
'center-by-alpha-png': {
title: 'Центрировать PNG по содержимому',
description: 'Находит непрозрачную часть изображения и размещает её по центру прежнего холста.'
},
'blur-png': {
title: 'Размытие PNG',
description: 'Гауссово размытие: три прохода разделяемого бокса — быстро при любом радиусе. Прозрачные края не темнеют.',
params: { radius: 'Радиус, px' }
},
'sharpen-png': {
title: 'Резкость PNG',
description: 'Подчёркивает края ядром резкости; сила задаёт смесь с оригиналом. 0% — без изменений.',
params: { strength: 'Сила, %' }
},
'grayscale-png': {
title: 'Чёрно-белый PNG',
description: 'Переводит изображение в оттенки серого по яркостной формуле BT.601. Альфа сохраняется.'
},
'invert-colors-png': {
title: 'Инвертировать цвета PNG',
description: 'Обращает каждый цветовой канал (255 − значение). Альфа не меняется.'
},
'adjust-brightness-contrast-png': {
title: 'Яркость и контраст PNG',
description: 'Изменяет яркость и контраст в диапазоне от −100 до +100. Значение 0 — без изменений.',
params: { brightness: 'Яркость', contrast: 'Контраст' }
},
'change-png-opacity': {
title: 'Изменить прозрачность PNG',
description: 'Умножает альфа-канал на процент: 0% — полностью прозрачный, 100% — без изменений.',
params: { percent: 'Прозрачность, %' }
},
'sepia-png': {
title: 'Эффект сепии',
description: 'Тонирует изображение в тёплые коричневые тона классической сепии.'
},
'change-png-hue': {
title: 'Сменить оттенок PNG',
description: 'Сдвиг цветового тона по кругу. Насыщенность и яркость сохраняются.',
params: { degrees: 'Сдвиг тона, °' }
},
'extract-channel-png': {
title: 'Извлечь канал PNG',
description: 'Оставляет выбранный канал — красный, зелёный или синий — в оттенках серого.',
params: { channel: 'Канал' },
options: { channel: { red: 'Красный', green: 'Зелёный', blue: 'Синий' } }
},
'swap-channels-png': {
title: 'Переставить каналы PNG',
description: 'Меняет местами два цветовых канала — быстрый способ получить необычный окрас.',
params: { pair: 'Пара каналов' },
options: {
pair: { 'r-g': 'Красный ↔ Зелёный', 'r-b': 'Красный ↔ Синий', 'g-b': 'Зелёный ↔ Синий' }
}
},
'black-and-white-png': {
title: 'Чёрно-белый PNG по порогу',
description: 'Жёсткая бинаризация по яркости: каждый пиксель становится чёрным или белым.',
params: { threshold: 'Порог яркости, %' }
},
'posterize-png': {
title: 'Постеризация PNG',
description: 'Уменьшает число уровней каждого канала — плакатный эффект.',
params: { levels: 'Уровней на канал' }
},
'two-colors-png': {
title: 'Два цвета PNG',
description: 'Перекрашивает изображение в два выбранных цвета по порогу яркости.',
params: {
lightColor: 'Цвет светлых участков',
darkColor: 'Цвет тёмных участков',
threshold: 'Порог яркости, %'
}
},
'convert-png-to-jpg': {
title: 'Конвертировать PNG в JPG',
description: 'Прозрачность накладывается на выбранный цвет подложки (по умолчанию белый), результат сохраняется в JPEG.',
params: { background: 'Цвет подложки', quality: 'Качество JPEG' }
},
'convert-png-to-webp': {
title: 'Конвертировать PNG в WebP',
description: 'Перекодирует изображение в WebP с настраиваемым качеством. Прозрачность сохраняется.',
params: { quality: 'Качество WebP' }
},
'remove-alpha-channel-png': {
title: 'Убрать альфа-канал PNG',
description: 'Накладывает изображение на белый фон и сохраняет без прозрачности.'
},
'set-alpha-channel-png': {
title: 'Задать альфа-канал PNG',
description: 'Присваивает всем пикселям одинаковую прозрачность, цвета не меняются.',
params: { percent: 'Прозрачность, %' }
},
'extract-alpha-mask-png': {
title: 'Извлечь маску альфы PNG',
description: 'Превращает прозрачность в чёрно-белую непрозрачную маску.'
},
'round-corners-png': {
title: 'Скруглить углы PNG',
description: 'Обрезает углы по радиусу, заданному в процентах от половины меньшей стороны.',
params: { radius: 'Радиус скругления, %' }
},
'invert-alpha-png': {
title: 'Инвертировать альфа-канал PNG',
description: 'Непрозрачные области становятся прозрачными и наоборот.'
},
'remove-background-png': {
title: 'Удалить фон PNG (умно)',
description: 'Убирает однотонный фон: по цвету с допуском, только внешние области от краёв или весь совпадающий цвет. Умеет сглаживать границу.',
params: {
color: 'Цвет фона',
tolerance: 'Допуск похожести, %',
outerOnly: 'Только внешние области',
smooth: 'Сглаживание границы, проходы'
}
},
'add-stroke-png': {
title: 'Обвести PNG',
description: 'Добавляет цветную обводку-кольцо вокруг непрозрачного содержимого заданной толщины.',
params: { color: 'Цвет обводки', thickness: 'Толщина, px' }
},
'find-contour-png': {
title: 'Найти контур PNG',
description: 'Оставляет только линию по границе непрозрачных областей выбранного цвета и толщины.',
params: { color: 'Цвет линии', thickness: 'Толщина линии, px' }
},
'make-thicker-png': {
title: 'Утолщить PNG',
description: 'Расширяет непрозрачные области на заданное число пикселей.',
params: { radius: 'На сколько px' }
},
'make-thinner-png': {
title: 'Утончить PNG',
description: 'Сужает непрозрачные области — утоньшает штрихи надписей и деталей.',
params: { radius: 'На сколько px' }
},
'harden-alpha-png': {
title: 'Жёсткие края PNG',
description: 'Бинаризует альфа-канал по порогу: полупрозрачные пиксели становятся либо полностью прозрачными, либо непрозрачными.',
params: { threshold: 'Порог альфы, %' }
},
'despeckle-alpha-png': {
title: 'Убрать мусор PNG',
description: 'Открытие: убирает одиночные полупрозрачные пиксели и мелкие крапинки.',
params: { radius: 'Радиус очистки, px' }
},
'close-holes-png': {
title: 'Закрыть дыры PNG',
description: 'Закрытие: заполняет одиночные прозрачные точки внутри объекта.',
params: { radius: 'Радиус закрытия, px' }
},
'remove-color-from-png': {
title: 'Удалить цвет из PNG (прозрачность)',
description: 'Делает прозрачными все пиксели, близкие к выбранному цвету. Порог задаёт допустимое отклонение в процентах от максимального цветового расстояния.',
params: { targetColor: 'Цвет для удаления', tolerance: 'Порог похожести, %' }
},
'png-info': {
title: 'Информация о PNG',
description: 'Показывает размеры, наличие альфа-канала и количество уникальных цветов загруженного изображения.'
},
'create-empty-png': {
title: 'Создать пустой PNG',
description: 'Генерирует холст выбранного размера — прозрачный или залитый цветом.',
params: { width: 'Ширина', height: 'Высота', transparent: 'Прозрачный', color: 'Цвет' }
},
'single-color-png': {
title: 'Создать одноцветный PNG',
description: 'Генерирует прямоугольник заданного размера и цвета.',
params: { width: 'Ширина', height: 'Высота', color: 'Цвет' }
},
'random-noise-png': {
title: 'Создать случайный шум PNG',
description: 'Генерирует картинку со случайными пикселями. Зерно фиксирует результат: одно зерно — одна картинка.',
params: { width: 'Ширина', height: 'Высота', seed: 'Зерно' }
},
'linear-gradient-png': {
title: 'Создать градиент PNG',
description: 'Генерирует плавный переход между двумя цветами по горизонтали или вертикали.',
params: {
width: 'Ширина',
height: 'Высота',
fromColor: 'Цвет начала',
toColor: 'Цвет конца',
direction: 'Направление'
},
options: { direction: { horizontal: 'По горизонтали', vertical: 'По вертикали' } }
},
'png-is-grayscale': {
title: 'Проверить: PNG монохромный?',
description: 'Сообщает, состоит ли изображение только из оттенков серого.',
results: {
grayscaleYes: 'Да — все пиксели являются оттенками серого.',
grayscaleNo: 'Нет — найдены цветные пиксели.'
}
},
'skew-png': {
title: 'Наклонить PNG',
description: 'Сдвигает содержимое по горизонтали и вертикали — эффект перспективы.',
params: { degX: 'Наклон по X, °', degY: 'Наклон по Y, °' }
},
'rotate-free-png': {
title: 'Повернуть на произвольный угол',
description: 'Поворот на любой угол. Холст расширяется под новые габариты, углы остаются прозрачными.',
params: { angle: 'Угол, °' }
},
'zoom-png': {
title: 'Приблизить PNG',
description: 'Увеличивает содержимое к центру. Холст прежнего размера — края обрезаются.',
params: { scale: 'Масштаб, %' }
},
'shift-png': {
title: 'Сдвинуть PNG',
description: 'Перемещает содержимое на заданное смещение по X и Y.',
params: { offsetX: 'Смещение X, px', offsetY: 'Смещение Y, px', color: 'Цвет фона' }
},
'vignette-png': {
title: 'Виньетка PNG',
description: 'Плавно затемняет края изображения, центр не затрагивает.',
params: { strength: 'Сила затемнения, %' }
},
'jpeg-artifacts-png': {
title: 'Артефакты JPEG',
description: 'Имитирует пережатие в JPEG с низким качеством — видимые квадраты и размытие цветов.',
params: { quality: 'Качество JPEG' }
},
'gamma-png': {
title: 'Гамма-коррекция PNG',
description: 'Корректирует яркость средних тонов. <1 темнее, >1 светлее, 1 — без изменений.',
params: { value: 'Гамма' }
},
'auto-contrast-png': {
title: 'Автоконтраст PNG',
description: 'Растягивает диапазон каждого канала на весь доступный диапазон яркости.'
},
'temperature-png': {
title: 'Температура PNG',
description: 'Положительные значения делают изображение теплее (оранжевее), отрицательные — холоднее (синеватее).',
params: { percent: 'Температура' }
},
'tint-png': {
title: 'Тонировать PNG',
description: 'Умножает цветовые каналы на выбранный оттенок с заданной силой.',
params: { color: 'Цвет тонирования', strength: 'Сила, %' }
},
'svg-to-png': {
title: 'SVG в PNG',
description: 'Декодирует SVG-разметку в растровое изображение. Вставьте SVG-код слева.',
params: { width: 'Ширина результата, px' }
},
'png-is-transparent': {
title: 'Проверить: PNG прозрачный?',
description: 'Сообщает, есть ли в изображении прозрачные или полупрозрачные пиксели.',
results: {
transparentYes: 'Да — есть прозрачные или полупрозрачные пиксели.',
transparentNo: 'Нет — все пиксели полностью непрозрачны.'
}
},
'png-orientation': {
title: 'Ориентация PNG',
description: 'Сообщает, портрет это, ландшафт или квадрат.',
results: {
orientationPortrait: 'Портрет — высота больше ширины.',
orientationLandscape: 'Ландшафт — ширина больше высоты.',
orientationSquare: 'Квадрат — стороны равны.'
}
}
}
};
+31
View File
@@ -0,0 +1,31 @@
import type { ParamDef, ToolEntry } from '$lib/registry';
import { getMergedDict } from './locale.svelte';
/**
* Строки инструмента на активной локали.
* Русский текст живёт в самом реестре (базовая локаль),
* переводы — в секции tools словарей; отсутствие ключа даёт фолбэк на реестр.
*/
export function toolTitle(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.title ?? tool.title;
}
export function toolDescription(tool: ToolEntry): string {
return getMergedDict().tools[tool.id]?.description ?? tool.description;
}
export function paramLabel(tool: ToolEntry, param: ParamDef): string {
const viaDict = getMergedDict().tools[tool.id]?.params?.[param.id];
if (viaDict) return viaDict;
const viaDef = param as { label?: string };
return viaDef.label ?? param.id;
}
export function optionLabel(tool: ToolEntry, param: ParamDef, value: string): string {
const viaDict = getMergedDict().tools[tool.id]?.options?.[param.id]?.[value];
if (viaDict) return viaDict;
if (param.type === 'select') {
return param.options.find((o) => o.value === value)?.label ?? value;
}
return value;
}
+77
View File
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest';
import { TOOLS } from '../registry';
import { en } from './en';
import { ru } from './ru';
describe('секция tools словарей покрывает реестр', () => {
it('у каждого инструмента есть перевод ru с непустыми title/description', () => {
for (const tool of TOOLS) {
const strings = ru.tools[tool.id];
expect(strings, `нет перевода для ${tool.id}`).toBeDefined();
expect(strings?.title, `${tool.id}: title`).toBeTruthy();
expect(strings?.description, `${tool.id}: description`).toBeTruthy();
}
});
it('переведены подписи всех параметров', () => {
for (const tool of TOOLS) {
const params = ru.tools[tool.id]?.params ?? {};
for (const param of tool.params) {
const label = params[param.id];
expect(label, `${tool.id}.${param.id}: нет перевода подписи`).toBeDefined();
expect(label?.length).toBeGreaterThan(0);
}
}
});
it('переведены подписи всех опций select', () => {
for (const tool of TOOLS) {
const options = ru.tools[tool.id]?.options ?? {};
for (const param of tool.params) {
if (param.type !== 'select') continue;
const labels = options[param.id] ?? {};
for (const option of param.options) {
const label = labels[option.value];
expect(label, `${tool.id}.${param.id}[${option.value}]: нет перевода`).toBeDefined();
expect(label.length).toBeGreaterThan(0);
}
}
}
});
it('в словарях нет лишних ключей инструментов и параметров', () => {
const ids = new Set(TOOLS.map((tool) => tool.id));
for (const dict of [ru.tools, en.tools]) {
for (const id of Object.keys(dict)) {
expect(ids.has(id), `лишний инструмент в словаре: ${id}`).toBe(true);
const tool = TOOLS.find((t) => t.id === id)!;
const paramIds = new Set(tool.params.map((p) => p.id));
for (const paramId of Object.keys(dict[id].params ?? {})) {
expect(paramIds.has(paramId), `${id}: лишний параметр ${paramId}`).toBe(true);
}
const selectIds = new Set(
tool.params.filter((p) => p.type === 'select').map((p) => p.id)
);
for (const paramId of Object.keys(dict[id].options ?? {})) {
expect(selectIds.has(paramId), `${id}: options у не-select параметра ${paramId}`).toBe(
true
);
}
}
}
});
it('тексты-результаты анализаторов переведены в обоих словарях', () => {
const expectations: Array<[string, string[]]> = [
['png-is-grayscale', ['grayscaleYes', 'grayscaleNo']],
['png-is-transparent', ['transparentYes', 'transparentNo']],
['png-orientation', ['orientationPortrait', 'orientationLandscape', 'orientationSquare']]
];
for (const [id, keys] of expectations) {
for (const key of keys) {
expect(en.tools[id]?.results?.[key], `${id}.${key} (en)`).toBeDefined();
expect(ru.tools[id]?.results?.[key], `${id}.${key} (ru)`).toBeDefined();
}
}
});
});
+238 -242
View File
File diff suppressed because it is too large Load Diff