summaryrefslogtreecommitdiff
path: root/l10n
diff options
from:
to:
context:
space:
mode:
authorGravatar Saya Andy <145215889+SayaAndy@users.noreply.github.com> 2026-05-18 01:33:14 +0700
committerGravatar GitHub <noreply@github.com> 2026-05-18 01:33:14 +0700
commit2c9d7e7c3110153c025c5324e0c4cc28d58cd2c1 (patch)
tree7d091ef937b8389d72ca3b0d28a6abaefb9c083e /l10n
parent55f0ca98d42b3d540870dc1c9430b9a26ff19807 (diff)
parenta43cbea2e501371390709005055c019459554bb2 (diff)
downloadweb-2c9d7e7c3110153c025c5324e0c4cc28d58cd2c1.tar.gz
web-2c9d7e7c3110153c025c5324e0c4cc28d58cd2c1.zip
v0.16.0 (#40)v0.16.0
* [feat: all color themes now have light & dark variations depending on preference](https://github.com/SayaAndy/saya-today-web/commit/44b72cbc80fb89788470bda75eeb9c0bb3dee2f5) * [feat: use self-hosted pmtiles for map](https://github.com/SayaAndy/saya-today-web/commit/fa52ed00e8ef3303f14d7c953f1591eb19042635) * [feat: subheader for blog pages with medley-related ones](https://github.com/SayaAndy/saya-today-web/commit/c07da125bc5b0bb65f58c4edce912874e46f6ed8) * feat: color all leaflet elements depending on color theme & scheme * [feat: combined map widget](https://github.com/SayaAndy/saya-today-web/pull/40/commits/6418da32d978a8b70d38f1911a154e4dcc33202e) * feat: migrate static & photos to gcore cdn * [refactor: rewrite localization for dynamic set and get](https://github.com/SayaAndy/saya-today-web/commit/e9353a66bf51486f702eaa0f591dea54c9bd4cd1) * [feat: highlight the blog page visited earlier in blog catalogue](https://github.com/SayaAndy/saya-today-web/commit/ed476a1f0d3f970f977626688ac316f8b251d3a0) * fix: inline shadow now sticks to scrolling * [feat: take all metadata from centralized index.json](https://github.com/SayaAndy/saya-today-web/commit/1c441f5eab9a2a24fe5309178ce010f32adac1c7) * [fix: font in like button on smartphone screens](https://github.com/SayaAndy/saya-today-web/commit/551295ecacebbb94019def022f70397f74c44775) * [feat: use "page // medley" format for title](https://github.com/SayaAndy/saya-today-web/pull/40/commits/0dbb85f25c69b5e7929bb832e7967a709a76ff5c) * feat: multitone backgrounds implementation * feat: change font to m-plus for short description * fix: theme wheel position on different dpi screens in global map * feat: integrate package minor updates * refactor: convert color vars into oklch * [refactor: rm scanning by listing for s3 client](https://github.com/SayaAndy/saya-today-web/commit/1a95e20cc4e050244a76a3ea4cccc5ab30ff5a91) * fix: on errors with blog client init write correct type
Diffstat (limited to 'l10n')
-rw-r--r--l10n/en.yaml79
-rw-r--r--l10n/ru.yaml79
-rw-r--r--l10n/translator-hook.go39
-rw-r--r--l10n/translator.go167
4 files changed, 364 insertions, 0 deletions
diff --git a/l10n/en.yaml b/l10n/en.yaml
new file mode 100644
index 0000000..9571aca
--- /dev/null
+++ b/l10n/en.yaml
@@ -0,0 +1,79 @@
+TagsLabel: "Tags:"
+BlogSearch:
+ Header: "Blog Search"
+ Description: "The Saya Blog posts' list"
+ TagsHeader: "Tags"
+ OrderByHeader: "Order by..."
+ TitleOrdered: "Title"
+ ActionDateOrdered: "Action Date"
+ PublicationDateOrdered: "Publication Date"
+ ChooseAllTags: "Choose All"
+GlobalMap:
+ Header: "Global Map"
+ Description: "Global map with all the tags of places I've been to"
+HomePage:
+ Header: "Home Page"
+ Contacts: "Contacts"
+ DidYouKnowThat: "Did you know, that..."
+ SidebarDescription: "Describing the Sidebar"
+ BackDescription: "This button will lead back to higher page (like Home => Language Pick, Blog Search => Home)"
+ HomePageDescription: "Home Page -- where we are right now!"
+ BlogSearchDescription: "Blog Search. You can find my travel notes there and, maybe, something else!"
+ MarkerMapDescription: "Map with markers of my travel notes -- a convinient way to find an interesting region and find related posts!"
+ ThemeSwitchDescription: "Theme Palette lets you change the theme of the site -- there are 2 dark and 3 bright themes currently!"
+ AccountDescription: "This is where you can subscribe to any blog update via E-mail!"
+ Hymn1: "We welcome you to Sayasite!"
+ Hymn2: "We've been expecting you!"
+ Hymn3: "You bring such joy in Sayasite!"
+ Hymn4: "No matter where you roam, know our love is true!~"
+UnsubscribePage:
+ Header: "Cancelling the Subscription"
+ UnsetCode: "Your cancel code is not set. You just having a walk around the site?"
+ InvalidCode: "Your cancel code could not be verified. Maybe, the code expired after a day. Or maybe, you've already unsubscribed."
+ OnServerError: "We couldn't unsubscribe you, the problem is on our side! We will solve this problem as soon as possible."
+ Success: "Cancelled your subscription successfully! We do hope we will engage you again someday."
+Metadata:
+ Published: "Published"
+ Action: "Took place on"
+Mail:
+ UnsubscribeFooter: "If this letter got you in a bad mood, you can unsubscribe from my blog by {}this link{/}."
+ VerifyEmail:
+ Subject: "Verification Link for SAYA.UZ is waiting for you!"
+ Welcome: "Greetings!"
+ Intro: "There are 2 ways for us to validate your profile:"
+ GotoLink: "Go by this link:"
+ InputCode: "Or input this code on the personal page:"
+ IfRandom: "If you are unsure what this letter this, you can ignore it, and also, if you are interested, you may visit my site :)"
+ NewPost:
+ Subject: "A new post arrived at SAYA.UZ!"
+ Intro: "A new post came!"
+ CapturedOn: "Captured on"
+UserProfile:
+ Header: "Personal Settings"
+ Description: "Set and verify your e-mail here, as well as subscribe to favorite blog tags"
+ EmailHeader: "E-Mail"
+ VerificationCodeHeader: "Verification Code"
+ TypeEmail: "Please type your email"
+ SendCodeButton: "Send Code"
+ VerifyButton: "Verify"
+ VerificationCodeSent: "Verification code sent! Please check your e-mail. Your next verification attempt will be possible on {}."
+ DelayTilVerification: "You have already requested the verification before! Please wait until {} for a new verification request."
+ FailedEmailRender: "Failed to render the email status message, please ask administrator for a more detailed cause."
+ VerificationCodeSendingError: "Failed to send a verification code, please ask administrator for a more detailed cause."
+ EmailAlreadyValidated: "You already have this E-Mail validated."
+ EmailTaken: "This E-Mail is already taken."
+ EmailEmpty: "E-Mail is unset or empty."
+ VerificationSuccess: "Success! You are now able to subscribe with your new e-mail."
+ VerificationFailed: "Verification code is invalid, please ask administrator for a more detailed cause."
+ VerificationEmpty: "Verification code is unset or empty."
+ SubscriptionHeader: "Subscribe to..."
+ TagsNone: "None"
+ TagsAll: "All"
+ TagsSpecific: "Specific"
+ TagDoesNotExist: 'The tag "{}" does not exist on the site.'
+ TagAlreadyAdded: 'The tag "{}" is already in your subscribed list.'
+ SaveButton: "Save"
+ SubscribeInvalidType: "Invalid subscription type."
+ FailedToSubscribe: "Failed to subscribe by new settings, please ask administrator for a more detailed cause."
+ SubscribedSuccessfully: "Successfully subscribed you by new settings!"
+ RefreshPage: "Please refresh the profile page on the device, where you requested the code."
diff --git a/l10n/ru.yaml b/l10n/ru.yaml
new file mode 100644
index 0000000..1818998
--- /dev/null
+++ b/l10n/ru.yaml
@@ -0,0 +1,79 @@
+TagsLabel: "Тэги:"
+BlogSearch:
+ Header: "Поиск по блогу"
+ Description: "Каталог записей Saya Blog"
+ TagsHeader: "Тэги"
+ OrderByHeader: "Упорядочить по..."
+ TitleOrdered: "названию"
+ ActionDateOrdered: "дате действия"
+ PublicationDateOrdered: "времени публикации"
+ ChooseAllTags: "Выбрать все"
+GlobalMap:
+ Header: "Глобальная карта"
+ Description: "Глобальная карта с метками мест, где я побывал"
+HomePage:
+ Header: "Домашняя страница"
+ Contacts: "Контакты"
+ DidYouKnowThat: "А вы знали, что..."
+ SidebarDescription: "Поясняем за сайдбар"
+ BackDescription: "Эта кнопка позволяет идти на страницу вверх (т.е. Домашняя страница => Выбор языка, Поиск по блогу => Домашняя страница)"
+ HomePageDescription: "Домашняя страница -- где мы сейчас и находимся!"
+ BlogSearchDescription: "Поиск по блогу. Там размещены мои путевые заметки и, может, что-то ещё!"
+ MarkerMapDescription: "Карта с маркерами моих путевых заметок, -- удобный способ найти интересный регион и перейти на заинтересовавшие посты!"
+ ThemeSwitchDescription: "Позволяет поменять тему сайта -- есть 3 светлые и 2 тёмные темы!"
+ AccountDescription: "Здесь можно подписаться на обновления в блоге, используя электронную почту!"
+ Hymn1: "Приветствуем Вас на Саясайте!"
+ Hymn2: "Мы Вас столько ждали!"
+ Hymn3: "Столько радости от Вас на Саясайте!"
+ Hymn4: "Где бы Вы не были, знайте, -- наша любовь крепка!"
+UnsubscribePage:
+ Header: "Отписка"
+ UnsetCode: "Код для отписки не проставлен. Вы случайно не заблудились?"
+ InvalidCode: "Код для отписки не подтверждён. Может, истекло время в сутки для кода. А может, вы уже успели отписаться."
+ OnServerError: "Мы не смогли вас отписать, проблема на нашей стороне! В скором времени займёмся проблемой."
+ Success: "Вы отписались от рассылки! Надеюсь, мы ещё сможем вас заинтересовать."
+Metadata:
+ Published: "Опубликовано"
+ Action: "Время действия"
+Mail:
+ UnsubscribeFooter: "Если данное письмо пришло вам случайно, либо вы хотите отписаться, можете перейти по {}этой ссылке{/}."
+ VerifyEmail:
+ Subject: "Ссылка для верификации для подтверждения вашей почты SAYA.UZ"
+ Welcome: "Приветствую!"
+ Intro: "Есть 2 способа, чтобы подтвердить свою электронную почту:"
+ GotoLink: "Перейти по этой ссылке:"
+ InputCode: "Либо ввести этот код на странице подписок:"
+ IfRandom: "Если ты не понимаешь, что это за спам, можешь проигнорировать это письмо, а ещё, если интересно, можешь посетить мой сайт :)"
+ NewPost:
+ Subject: "Новый пост на SAYA.UZ!"
+ Intro: "Вышел новый пост!"
+ CapturedOn: "Снималось"
+UserProfile:
+ Header: "Личные настройки"
+ Description: "Здесь можно настроить и верифицировать свою электронную почту, а также подписаться на избранные темы блога"
+ EmailHeader: "E-Mail"
+ VerificationCodeHeader: "Код верификации"
+ TypeEmail: "Пожалуйста, введите свою почту"
+ SendCodeButton: "Отправить код"
+ VerifyButton: "Верифицировать"
+ VerificationCodeSent: "Выслали вам код верификации! Проверьте свою почту. Следующая попытка верификации будет возможна {}."
+ DelayTilVerification: "Вы уже запрашивали недавно верификацию! Дождитесь {} для новой попытки."
+ FailedEmailRender: "Неудачная попытка создать электронное письмо, более подробно спрашивайте у администратора."
+ VerificationCodeSendingError: "Неудачная попытка прислать код для верификации, более подробно спрашивайте у администратора."
+ EmailAlreadyValidated: "Эту почту вы уже подтверждали."
+ EmailTaken: "Эта почта уже занята."
+ EmailEmpty: "Почта не введена."
+ VerificationSuccess: "Поздравляем! Теперь по этой почте можно подписаться на блог."
+ VerificationFailed: "Код для верификации невалиден, при вопросах, пожалуйста, обращайтесь к администратору."
+ VerificationEmpty: "Код для верификации не введён."
+ SubscriptionHeader: "Подписаться на..."
+ TagsNone: "Ничего"
+ TagsAll: "Всё"
+ TagsSpecific: "Определённое"
+ TagDoesNotExist: 'Тэга "{}" нет на сайте.'
+ TagAlreadyAdded: 'Тэг "{}" уже у вас добавлен.'
+ SaveButton: "Сохранить"
+ SubscribeInvalidType: "Неизвестный тип подписки."
+ FailedToSubscribe: "Неудачная попытка записать новую подписку, более подробно спрашивайте у администратора."
+ SubscribedSuccessfully: "Вы успешно подписались по новым настройкам!"
+ RefreshPage: "Пожалуйста, обновите страницу с профилем на устройстве, откуда запрашивали код."
diff --git a/l10n/translator-hook.go b/l10n/translator-hook.go
new file mode 100644
index 0000000..ff5837c
--- /dev/null
+++ b/l10n/translator-hook.go
@@ -0,0 +1,39 @@
+package l10n
+
+var hooks map[string]*TranslatorHook
+
+type TranslatorHook struct {
+ t *Translator
+ prefix string
+}
+
+func NewTranslatorHook(prefix string) *TranslatorHook {
+ if _, ok := hooks[prefix]; !ok {
+ hooks[prefix] = &TranslatorHook{T, prefix}
+ }
+ return hooks[prefix]
+}
+
+func (h *TranslatorHook) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) {
+ return h.t.Set(h.prefix+key, val, toOverwrite)
+}
+
+func (h *TranslatorHook) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) {
+ return h.t.SetPath(val, toOverwrite, append([]any{h.prefix}, path...)...)
+}
+
+func (h *TranslatorHook) Get(key string) any {
+ return h.t.Get(h.prefix + "." + key)
+}
+
+func (h *TranslatorHook) GetPath(path ...any) any {
+ return h.t.GetPath(append([]any{h.prefix}, path...)...)
+}
+
+func (h *TranslatorHook) GetDefault(key string, def any) any {
+ return h.t.GetDefault(h.prefix+"."+key, def)
+}
+
+func (h *TranslatorHook) GetDefaultPath(def any, path ...any) any {
+ return h.t.GetDefaultPath(def, append([]any{h.prefix}, path...)...)
+}
diff --git a/l10n/translator.go b/l10n/translator.go
new file mode 100644
index 0000000..4d4b4eb
--- /dev/null
+++ b/l10n/translator.go
@@ -0,0 +1,167 @@
+package l10n
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "os"
+ "strings"
+ "sync"
+
+ "gopkg.in/yaml.v3"
+)
+
+var T *Translator
+
+func init() {
+ var err error
+ if T, err = NewTranslator("ru", "en"); err != nil {
+ slog.Error("fail to load localization", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+}
+
+type Translator struct {
+ l map[string]any
+ mu sync.RWMutex
+}
+
+func NewTranslator(locales ...string) (*Translator, error) {
+ t := &Translator{l: make(map[string]any)}
+ errs := make([]error, 0)
+
+ for _, locale := range locales {
+ contentBytes, err := os.ReadFile(fmt.Sprintf("l10n/%s.yaml", locale))
+ if err != nil {
+ errs = append(errs, fmt.Errorf("opening one of files: l10n/%s.yaml: %w", locale, err))
+ continue
+ }
+
+ var content map[string]any
+ if err := yaml.Unmarshal(contentBytes, &content); err != nil {
+ errs = append(errs, fmt.Errorf("parsing one of files: l10n/%s.yaml: %w", locale, err))
+ continue
+ }
+
+ errs = append(errs, t.initMap("."+locale, content)...)
+ }
+
+ return t, errors.Join(errs...)
+}
+
+func (t *Translator) Set(key string, val any, toOverwrite bool) (alreadySet bool, err error) {
+ t.mu.RLock()
+ if _, alreadySet = t.l[key]; !alreadySet || (alreadySet && toOverwrite) {
+ t.mu.RUnlock()
+ return alreadySet, errors.Join(t.setSafe(key, val)...)
+ }
+ t.mu.RUnlock()
+ return alreadySet, nil
+}
+
+func (t *Translator) SetPath(val any, toOverwrite bool, path ...any) (alreadySet bool, err error) {
+ key, err := formatPath(path...)
+ if err != nil {
+ return false, err
+ }
+ return t.Set(key, val, toOverwrite)
+}
+
+func (t *Translator) Get(key string) any {
+ return t.GetDefault(key, key[strings.LastIndex(key, ".")+1:])
+}
+
+func (t *Translator) GetPath(path ...any) any {
+ return t.GetDefaultPath(path[len(path)-1], path...)
+}
+
+func (t *Translator) GetDefault(key string, def any) any {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ if v, ok := t.l[key]; ok {
+ return v
+ }
+ slog.Warn("failed to find localization for a key", slog.String("key", key))
+ return def
+}
+
+func (t *Translator) GetDefaultPath(def any, path ...any) any {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ key, err := formatPath(path...)
+ if err != nil {
+ slog.Error("get localized value by path: path is invalid", slog.String("error", err.Error()))
+ return def
+ }
+ return t.GetDefault(key, def)
+}
+
+func (t *Translator) initSlice(parent string, level []any) []error {
+ errs := make([]error, 0)
+ for i, v := range level {
+ newParent := parent + fmt.Sprintf("[%d]", i)
+ t.setSafe(newParent, v)
+ }
+ return errs
+}
+
+func (t *Translator) initMap(parent string, level map[string]any) []error {
+ errs := make([]error, 0)
+ for k, v := range level {
+ key, err := formatPath(k)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+
+ errs = append(errs, t.setSafe(parent+key, v)...)
+ }
+ return errs
+}
+
+func (t *Translator) setSafe(k string, v any) []error {
+ errs := make([]error, 0)
+ t.mu.Lock()
+ switch vv := v.(type) {
+ case int:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case float64:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case string:
+ t.l[k] = vv
+ t.mu.Unlock()
+ case []any:
+ t.mu.Unlock()
+ errs = append(errs, t.initSlice(k, vv)...)
+ case map[string]any:
+ t.mu.Unlock()
+ errs = append(errs, t.initMap(k, vv)...)
+ default:
+ t.mu.Unlock()
+ errs = append(errs, fmt.Errorf("invalid value type: %s, %v", k, vv))
+ }
+ return errs
+}
+
+func formatPath(path ...any) (string, error) {
+ var key string
+ for _, part := range path {
+ switch k := part.(type) {
+ case int:
+ key += fmt.Sprintf("[%d]", k)
+ case string:
+ key += "." + strings.ReplaceAll(k, ".", "\\.")
+ case []any:
+ add, err := formatPath(k...)
+ if err != nil {
+ return "", fmt.Errorf("while formatting nested slice: %w", err)
+ }
+ key += add
+ default:
+ return "", fmt.Errorf("invalid key part: after %s follows %v", key, k)
+ }
+ }
+ return key, nil
+}