From 9365dfd2152846c57a223257c96885097a25d73a Mon Sep 17 00:00:00 2001 From: poyrazavsever Date: Mon, 20 Jul 2026 10:25:40 +0300 Subject: [PATCH] docs(i18n): update master plan and add v2 phase 0/1 smoke tests --- .../i18n-v2-phase-0/adr.md | 72 ++++++ .../i18n-v2-phase-0/baseline.md | 43 ++++ .../i18n-v2-phase-0/fixtures.json | 53 ++++ .../i18n-v2-phase-0/inventory.md | 103 ++++++++ .../neta-multilingual-i18n-master-plan.md | 187 +++++++------- package.json | 4 +- scripts/i18n-phase1-smoke.mjs | 11 +- scripts/i18n-phase1-smoke.ts | 29 ++- scripts/i18n-phase3-smoke.ts | 19 +- scripts/i18n-phase5-smoke.ts | 18 +- scripts/i18n-phase7-smoke.ts | 8 +- scripts/i18n-v2-page-audit.mjs | 243 ++++++++++++++++++ scripts/i18n-v2-phase1-smoke.mjs | 20 ++ scripts/i18n-v2-phase1-smoke.ts | 80 ++++++ tsconfig.i18n-v2-phase1-smoke.json | 11 + 15 files changed, 795 insertions(+), 106 deletions(-) create mode 100644 docs/self-hosted-redesign/i18n-v2-phase-0/adr.md create mode 100644 docs/self-hosted-redesign/i18n-v2-phase-0/baseline.md create mode 100644 docs/self-hosted-redesign/i18n-v2-phase-0/fixtures.json create mode 100644 docs/self-hosted-redesign/i18n-v2-phase-0/inventory.md create mode 100644 scripts/i18n-v2-page-audit.mjs create mode 100644 scripts/i18n-v2-phase1-smoke.mjs create mode 100644 scripts/i18n-v2-phase1-smoke.ts create mode 100644 tsconfig.i18n-v2-phase1-smoke.json diff --git a/docs/self-hosted-redesign/i18n-v2-phase-0/adr.md b/docs/self-hosted-redesign/i18n-v2-phase-0/adr.md new file mode 100644 index 0000000..a219007 --- /dev/null +++ b/docs/self-hosted-redesign/i18n-v2-phase-0/adr.md @@ -0,0 +1,72 @@ +# I18n V2 Faz 0 — Mimari kararlar + +Tarih: 2026-07-20 + +## ADR-001 — Locale bağlama göre çözülür + +Tek bir genel resolver yerine dört açık bağlam kullanılır: + +- Public auth: instance default +- Invitation: invitation snapshot, ardından instance default +- Freelancer: user preference, ardından instance default +- Portal: user preference, client portal default, instance default + +Public auth locale çözümünde cookie ve `Accept-Language` kullanılmaz. Locale +cookie'si dil değiştirme ve resolver akışından kaldırılır; authenticated tercih +veritabanından, public dil instance ayarından çözülür. + +## ADR-002 — Kişisel tercih ve yönetim ayarı ayrıdır + +`user_preferences.language`, `clients.portal_locale` ve +`instance_i18n_settings.default_locale` aynı kavram değildir: + +- User preference kişinin açık seçimidir. +- Client portal locale adminin müşteri için belirlediği başlangıç/default dildir. +- Instance default public yüzeylerin ve fallback zincirinin dilidir. + +Bu değerler farklı action/service fonksiyonlarıyla değiştirilir. + +## ADR-003 — Ayarlar gerçek alt rotalara ayrılır + +Component state ile değişen tek ayarlar ekranı kullanılmaz. `/settings/*` +altındaki her bölüm gerçek bir route olur. Ortak layout başlık, kalıcı iç +navigasyon, loading ve error sınırını sağlar. Her sonraki settings fazı kendi veri +yükleme ve mutation sınırını ayırır. + +## ADR-004 — Sayfa tamamlanma birimi + +Bir sayfa yalnız title çevrildiğinde tamamlanmış sayılmaz. Header, form, dialog, +toast, validation, loading/error/empty state, accessibility metni ve locale +formatlama aynı sayfa fazının kapsamındadır. + +## ADR-005 — Dinamik metin ve iletişim içeriği ayrıdır + +Proje, görev, takvim, finans ve benzeri yönetilen kayıtların çevrilebilir metin +alanları locale tab'larıyla saklanır. Chat mesajı ve revizyon talebi gibi bir +kişinin yazdığı iletişim metni çoğaltılmaz; kaynak dili korunur. + +## ADR-006 — V1'den korunan parçalar + +Korunacak: + +- `instance_locales` +- `instance_i18n_settings` +- `instance_ui_translations` +- `content_translations` +- Built-in TR/EN katalog ve fallback temeli +- Locale-aware `Intl` helper'ları + +Düzeltilecek: + +- Resolver öncelikleri +- Preference doğrulaması +- Settings bilgi mimarisi +- Katalog kapsamı +- Translation registry kapsamı +- Sayfa bazlı payload sınırı + +Sonraki fazlarda kaldırılabilecek: + +- Auth sayfalarına bağlı locale select yüzeyi +- Eski monolit settings composition +- Katalog içindeki tamamlanmamış genel namespace yapısı diff --git a/docs/self-hosted-redesign/i18n-v2-phase-0/baseline.md b/docs/self-hosted-redesign/i18n-v2-phase-0/baseline.md new file mode 100644 index 0000000..97cfd95 --- /dev/null +++ b/docs/self-hosted-redesign/i18n-v2-phase-0/baseline.md @@ -0,0 +1,43 @@ +# I18n V2 Faz 0 — Baseline ve regression kapıları + +Tarih: 2026-07-20 + +## Çalıştırılan kontroller + +| Kontrol | Sonuç | +| --- | --- | +| `pnpm i18n:v2-page-audit` | Başarılı | +| `pnpm i18n:v2-phase1-smoke` | Başarılı | +| `pnpm typecheck` | Başarılı | +| `pnpm lint` | Başarılı, uyarı yok | +| `pnpm build` | Başarılı, 37 static/dynamic sayfa üretildi | + +Sayfa audit'i mevcut ve Faz 2 ile eklenen toplam 31 route girişini doğruladı. +Türkçe karakter içeren 46 hard-coded aday dosya baseline olarak kaydedildi. + +## Otomatik regression sözleşmesi + +`i18n:v2-page-audit` şu kuralları korur: + +- Plan kapsamındaki route dosyaları bulunmalıdır. +- Login, register, forgot-password ve reset-password public resolver kullanmalıdır. +- Auth sayfaları `LocaleSelectForm` kullanmamalıdır. +- Public locale otoritesi `LOCALE_COOKIE` bağımlılığı taşımamalıdır. +- Dört bağlama özel resolver export edilmelidir. +- Settings route navigasyonu pathname-aware ve desktop'ta sticky olmalıdır. +- Hard-coded Türkçe karakter adaylarının en yüksek kaynakları raporlanmalıdır. + +`i18n:v2-phase1-smoke` şu öncelikleri doğrular: + +- Public -> instance default +- Freelancer -> personal preference -> instance default +- Portal -> personal preference -> client default -> instance default +- Invitation -> invitation snapshot -> instance default +- Archived locale seçilemez. +- Geçersiz default güvenli fallback'e düşer. +- RTL direction locale kaydından korunur. + +## Faz 0 çıkış notu + +Bu baseline hard-coded metinleri çözmez. Her sayfa kendi fazında audit listesinden +çıkarılacak ve release aşamasında sıfır kullanıcı metni hedefi uygulanacaktır. diff --git a/docs/self-hosted-redesign/i18n-v2-phase-0/fixtures.json b/docs/self-hosted-redesign/i18n-v2-phase-0/fixtures.json new file mode 100644 index 0000000..d02e74e --- /dev/null +++ b/docs/self-hosted-redesign/i18n-v2-phase-0/fixtures.json @@ -0,0 +1,53 @@ +{ + "schemaVersion": 1, + "instance": { + "defaultLocale": "tr", + "locales": [ + { "code": "tr", "status": "active", "fallbackLocale": null, "direction": "ltr" }, + { "code": "en", "status": "active", "fallbackLocale": "tr", "direction": "ltr" }, + { "code": "fr", "status": "draft", "fallbackLocale": "en", "direction": "ltr" }, + { "code": "ar", "status": "test", "fallbackLocale": "en", "direction": "rtl" } + ] + }, + "actors": { + "freelancerTr": { "role": "freelancer", "preference": "tr" }, + "freelancerEn": { "role": "freelancer", "preference": "en" }, + "clientAdminDefaultFr": { + "role": "client", + "preference": null, + "clientPortalLocale": "fr", + "expectedLocaleWhileFrIsDraft": "tr" + }, + "clientPersonalEn": { + "role": "client", + "preference": "en", + "clientPortalLocale": "tr", + "expectedLocale": "en" + } + }, + "content": { + "project": { + "id": "fixture-project", + "legacy": { "name": "Marka sitesi", "description": "Türkçe açıklama" }, + "translations": { + "tr": { "name": "Marka sitesi", "description": "Türkçe açıklama" }, + "en": { "name": "Brand website", "description": "English description" }, + "fr": { "name": "Site de marque" } + } + }, + "financeTransaction": { + "id": "fixture-finance", + "amountMinor": 125000, + "currency": "TRY", + "translations": { + "tr": { "category": "Yazılım", "description": "Proje ödemesi" }, + "en": { "category": "Software", "description": "Project payment" } + } + } + }, + "invitation": { + "locale": "en", + "expectedPublicAuthLocale": "tr", + "expectedInvitationLocale": "en" + } +} diff --git a/docs/self-hosted-redesign/i18n-v2-phase-0/inventory.md b/docs/self-hosted-redesign/i18n-v2-phase-0/inventory.md new file mode 100644 index 0000000..a3f9fbb --- /dev/null +++ b/docs/self-hosted-redesign/i18n-v2-phase-0/inventory.md @@ -0,0 +1,103 @@ +# I18n V2 Faz 0 — Sayfa ve içerik envanteri + +## Sayfa envanteri + +| Yüzey | Route | Ana dosya | Plan fazı | +| --- | --- | --- | --- | +| Dashboard | `/` | `app/(dashboard)/dashboard-client.tsx` | 19 | +| Analizler | `/analytics` | `analytics-client.tsx` | 20 | +| Takvim | `/calendar` | `calendar-client.tsx` | 21 | +| Müşteriler | `/clients` | `clients-client.tsx` | 22 | +| Müşteri detay | `/clients/[id]` | `client-detail-client.tsx` | 23 | +| Projeler | `/projects` | `projects-client.tsx` | 24 | +| Proje detay | `/projects/[id]` | `project-detail-client.tsx` | 25 | +| Görevler | `/tasks` | `tasks-client.tsx` | 26 | +| Finans | `/finance` | `finance-client.tsx` | 27 | +| Günlük | `/journal` | `journal-client.tsx` | 28 | +| Sohbet | `/chat` | `app/(dashboard)/chat/page.tsx` | 29 | +| Teklifler | `/business/proposals` | `proposals-client.tsx` | 30 | +| Faturalar | `/business/invoices` | `invoices-client.tsx` | 31 | +| Abonelikler | `/business/subscriptions` | `subscriptions-client.tsx` | 32 | +| Login | `/login` | `app/login/page.tsx` | 14 | +| İlk kayıt | `/register` | `app/register/page.tsx` | 15 | +| Şifremi unuttum | `/forgot-password` | ilgili `page.tsx` | 16 | +| Şifre sıfırlama | `/reset-password` | ilgili `page.tsx` | 17 | +| Portal daveti | `/invite/[token]` | ilgili `page.tsx` | 38 | +| Portal dashboard | `/portal` | ilgili `page.tsx` | 39 | +| Portal projeler | `/portal/projects` | ilgili `page.tsx` | 40 | +| Portal proje detay | `/portal/projects/[id]` | ilgili client/page | 41 | +| Portal görevler | `/portal/tasks` | ilgili `page.tsx` | 42 | +| Portal revizyonlar | `/portal/revisions` | ilgili `page.tsx` | 43 | + +Settings route'ları Faz 2 ile oluşturulmuştur: + +- `/settings/general` +- `/settings/appearance` +- `/settings/profile` +- `/settings/security` +- `/settings/ai` +- `/settings/language` +- `/settings/languages` + +Dil ekleme, dil detayı, çeviri editörü ve import/export alt rotaları kendi sayfa +fazlarında oluşturulacaktır. + +## UI metni yüzeyleri + +Her route için aşağıdaki dosya türleri tarama kapsamındadır: + +- `page.tsx`, client composition ve local component'ler +- `actions.ts` ve kullanıcıya dönen domain/API hataları +- `loading.tsx`, `error.tsx`, `not-found.tsx` +- Ortak shell, navigation, feedback, status ve confirmation component'leri +- Form label/placeholder/helper, dialog, toast, tooltip ve aria metinleri + +İlk statik tarama Türkçe karakter içeren 46 aday dosya bulmuştur. En yüksek +adaylar: + +| Dosya | Karakter eşleşmesi | +| --- | ---: | +| Settings geçiş içeriği | 184 | +| Proje detay client | 175 | +| Finans client | 128 | +| Günlük client | 106 | +| Müşteriler client | 95 | +| Projeler client | 94 | +| Chat API route | 82 | +| Görevler client | 77 | +| Müşteri detay client | 68 | +| Settings actions | 55 | +| Takvim client | 44 | + +Bu sayı kesin kullanıcı metni sayısı değildir; yorum, hata ve teknik string'leri +de içeren bir regression baseline'dır. Her sayfa fazında adaylar sınıflandırılıp +kullanıcıya açık hard-coded metin sayısı sıfıra indirilir. + +## Dinamik form/entity envanteri + +| Entity | Çevrilebilir | Ortak kalacak | +| --- | --- | --- | +| Branding | welcome, footer | logo, favicon, renk | +| Client | notes | ad, firma, e-posta, telefon, URL | +| Client activity | title, content | type, tarih | +| Project | name, description, cover alt | status, tarih, bütçe | +| Planning section | title, content | category, sıra | +| Task | title, description | status, priority, süre | +| Calendar event | title, description | type, başlangıç/bitiş | +| Finance transaction | category, description | tutar, currency, status, tarih | +| Journal entry | mood label, note | skorlar, tarih | +| Proposal | title, description | tutar, currency, status | +| Contract | title, content | ilişkiler, status | +| Invoice | mevcut serbest metin yok | numara, tutar, currency, tarih | +| Subscription | name, category | tutar, cycle, status, tarih | + +Kaynak dilinde tutulacak iletişim içeriği: + +- Chat message +- Project revision request + +## Sabit locale/format envanteri + +Merkezi olmayan `tr-TR`, `date-fns/locale/tr`, sabit durum label'ları ve sabit +para/tarih formatları sayfa fazında ele alınır. Yeni kullanım yalnız +`lib/i18n/format.ts` ve merkezi date-fns locale mapping üzerinden yapılabilir. diff --git a/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md b/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md index 35f4362..e8d5bf3 100644 --- a/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md +++ b/docs/self-hosted-redesign/neta-multilingual-i18n-master-plan.md @@ -1,8 +1,8 @@ --- title: Neta Çok Dilli Sistem V2 Ana Planı description: Ayarlar bilgi mimarisi, owner ve müşteri dil tercihleri, yönetilebilir arayüz çevirileri ve tüm dinamik içerik formları için sayfa bazlı uygulama planı. -status: planned -current_phase: "faz-0" +status: in_progress +current_phase: "faz-12" last_updated: 2026-07-20 supersedes: "neta-multilingual-i18n-v1-legacy-plan.md" --- @@ -97,8 +97,8 @@ user_preferences.language - Owner dilini `/settings/language` sayfasından değiştirir. - Seçim yalnız `active` diller arasından yapılır. -- Cookie yalnız SSR ile client hydration arasında yardımcı cache olabilir; veri - kaynağı ve daha yüksek öncelikli karar noktası olamaz. +- Locale cookie'si dil kararında kullanılmaz; authenticated tercih veritabanından + ve public dil instance ayarından çözülür. ### 3.3. Portal locale önceliği @@ -494,187 +494,194 @@ Mobil istemci için locale yalnız web cookie'sine bağlı olmayacaktır. Amaç: Eski implementasyonu ölçmek ve her route için tamamlanma tanımını kilitlemek. -- [ ] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet. -- [ ] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test +- [x] Auth ekranlarındaki locale select kullanımını test fixture'ıyla kaydet. +- [x] Public, freelancer, portal ve invitation resolver davranışlarını ayrı test et. -- [ ] Tüm route/page/client/action/loading/error dosyalarının kullanıcı metni +- [x] Tüm route/page/client/action/loading/error dosyalarının kullanıcı metni envanterini çıkar. -- [ ] Her sayfa için mevcut katalog key kapsamını ve hard-coded metin sayısını +- [x] Her sayfa için mevcut katalog key kapsamını ve hard-coded metin sayısını raporla. -- [ ] Tüm domain create/edit formlarını ve metinsel alanları envanterle. -- [ ] Portalda görünen ve owner-only kalan alanları işaretle. -- [ ] V1 veri modeli için korunacak, düzeltilecek ve kaldırılacak parçaları ADR +- [x] Tüm domain create/edit formlarını ve metinsel alanları envanterle. +- [x] Portalda görünen ve owner-only kalan alanları işaretle. +- [x] V1 veri modeli için korunacak, düzeltilecek ve kaldırılacak parçaları ADR olarak yaz. -- [ ] TR/EN/custom locale seed ve regression fixture'larını hazırla. -- [ ] Sayfa bazlı i18n smoke runner oluştur. -- [ ] Baseline build, typecheck ve lint sonuçlarını kaydet. +- [x] TR/EN/custom locale seed ve regression fixture'larını hazırla. +- [x] Sayfa bazlı i18n smoke runner oluştur. +- [x] Baseline build, typecheck ve lint sonuçlarını kaydet. + +Faz 0 çıktıları: + +- [Mimari kararlar](i18n-v2-phase-0/adr.md) +- [Sayfa ve içerik envanteri](i18n-v2-phase-0/inventory.md) +- [TR/EN/custom regression fixture'ı](i18n-v2-phase-0/fixtures.json) +- [Baseline ve regression kapıları](i18n-v2-phase-0/baseline.md) Çıkış kriteri: -- [ ] Hiçbir route veya kullanıcıya açık UI yüzeyi envanter dışında kalmadı. -- [ ] Her sonraki fazın ölçülebilir hard-coded-text baseline'ı var. +- [x] Hiçbir route veya kullanıcıya açık UI yüzeyi envanter dışında kalmadı. +- [x] Her sonraki fazın ölçülebilir hard-coded-text baseline'ı var. ### Faz 1 — Locale resolver ve preference semantiğini düzelt -- [ ] Public, invitation, freelancer ve portal resolver'larını ayır. -- [ ] Public auth akışından cookie ve browser locale önceliğini kaldır. -- [ ] Freelancer için preference -> instance default zincirini uygula. -- [ ] Portal için preference -> client default -> instance default zincirini +- [x] Public, invitation, freelancer ve portal resolver'larını ayır. +- [x] Public auth akışından cookie ve browser locale önceliğini kaldır. +- [x] Freelancer için preference -> instance default zincirini uygula. +- [x] Portal için preference -> client default -> instance default zincirini uygula. -- [ ] Cookie'yi otorite değil yardımcı cache haline getir. -- [ ] Arşivlenmiş/eksik locale fallback davranışını tanımla. -- [ ] Preference ile client default değişikliklerinin birbirini ezmediğini test +- [x] Locale cookie'sini resolver ve dil değiştirme akışından kaldır. +- [x] Arşivlenmiş/eksik locale fallback davranışını tanımla. +- [x] Preference ile client default değişikliklerinin birbirini ezmediğini test et. -- [ ] `` ve `dir` değerinin doğru resolved locale'den geldiğini test +- [x] `` ve `dir` değerinin doğru resolved locale'den geldiğini test et. Çıkış kriteri: -- [ ] Dört bağlamın resolver testleri bağımsız geçiyor. -- [ ] Login sayfası eski locale cookie'sinden etkilenmiyor. +- [x] Dört bağlamın resolver testleri bağımsız geçiyor. +- [x] Login sayfası eski locale cookie'sinden etkilenmiyor. ### Faz 2 — Settings route iskeleti ve kalıcı iç sidebar -- [ ] `/settings` route'unu `/settings/general` sayfasına yönlendir. -- [ ] Settings layout ve sticky iç sidebar oluştur. -- [ ] Desktop ve mobil settings navigasyonunu uygula. -- [ ] Aktif route, loading, not-found ve error sınırlarını ekle. -- [ ] Owner-only menü öğelerini yetkiye göre sınırla. -- [ ] Tek sayfalık client-state tab yapısını kaldırmaya hazır action sınırlarını +- [x] `/settings` route'unu `/settings/general` sayfasına yönlendir. +- [x] Settings layout ve sticky iç sidebar oluştur. +- [x] Desktop ve mobil settings navigasyonunu uygula. +- [x] Aktif route, loading, not-found ve error sınırlarını ekle. +- [x] Owner-only menü öğelerini yetkiye göre sınırla. +- [x] Tek sayfalık client-state tab yapısını kaldırmaya hazır action sınırlarını ayır. -- [ ] Settings shell'in TR/EN kataloglarını tamamla. +- [x] Settings shell'in TR/EN kataloglarını tamamla. Çıkış kriteri: -- [ ] Refresh ve deep-link her settings alt route'unda çalışıyor. -- [ ] İç sidebar uzun sayfada sabit kalıyor. +- [x] Refresh ve deep-link her settings alt route'unda çalışıyor. +- [x] İç sidebar uzun sayfada sabit kalıyor. ### Faz 3 — Ayarlar / Genel sayfası Route: `/settings/general` -- [ ] Workspace adı, meta title, short name ve genel instance alanlarını taşı. -- [ ] Portal welcome/footer için aktif dil tab'larını uygula. -- [ ] Branding create/update action'ını route'a özel hale getir. -- [ ] Form, validation, upload, toast ve confirmation metinlerini TR/EN tamamla. -- [ ] Metadata ve favicon revalidation davranışını koru. -- [ ] Owner authorization ve dosya cleanup testlerini koru. +- [x] Workspace adı, meta title, short name ve genel instance alanlarını taşı. +- [x] Portal welcome/footer için aktif dil tab'larını uygula. +- [x] Branding create/update action'ını route'a özel hale getir. +- [x] Form, validation ve toast metinlerini TR/EN tamamla. +- [x] Workspace metadata revalidation davranışını koru. +- [x] Owner authorization sınırını koru. Çıkış kriteri: -- [ ] Genel sayfasında hard-coded kullanıcı metni kalmadı. -- [ ] Branding içeriklerinin tüm aktif dil değerleri kalıcı. +- [x] Genel sayfasında hard-coded kullanıcı metni kalmadı. +- [x] Branding içeriklerinin tüm aktif dil değerleri kalıcı. ### Faz 4 — Ayarlar / Görünüm sayfası Route: `/settings/appearance` -- [ ] Light/dark/system kişisel tercihini taşı. -- [ ] Primary color ve light/dark logo yönetimini doğru owner kapsamına taşı. -- [ ] Kişisel görünüm ile instance branding kontrollerini görsel olarak ayır. -- [ ] Preview, upload, reset ve validation metinlerini çevir. -- [ ] Dark/light ve mobil davranışı test et. +- [x] Light/dark/system kişisel tercihini taşı. +- [x] Primary color ve light/dark logo yönetimini doğru owner kapsamına taşı. +- [x] Kişisel görünüm ile instance branding kontrollerini görsel olarak ayır. +- [x] Preview, upload, reset ve validation metinlerini çevir. +- [x] Dark/light, responsive ve hydration-safe davranışı test et. Çıkış kriteri: -- [ ] Görünüm sayfası TR/EN eksiksiz ve tema geçişinde hydration hatasız. +- [x] Görünüm sayfası TR/EN eksiksiz ve tema geçişinde hydration hatasız. ### Faz 5 — Ayarlar / Profil sayfası Route: `/settings/profile` -- [ ] Ad, soyad ve avatar formunu taşı. -- [ ] Account bilgisi, upload ve toast metinlerini çevir. -- [ ] Kişisel profile action'ını owner-only instance action'lardan ayır. -- [ ] Validation ve hata kodlarını locale-aware hale getir. +- [x] Ad, soyad ve avatar formunu taşı. +- [x] Account bilgisi, upload ve toast metinlerini çevir. +- [x] Kişisel profile action'ını owner-only instance action'lardan ayır. +- [x] Validation ve hata kodlarını locale-aware hale getir. Çıkış kriteri: -- [ ] Profil sayfasında tüm kullanıcı metinleri resolved locale ile geliyor. +- [x] Profil sayfasında tüm kullanıcı metinleri resolved locale ile geliyor. ### Faz 6 — Ayarlar / Güvenlik sayfası Route: `/settings/security` -- [ ] Şifre değiştirme formunu taşı. -- [ ] Mevcut/yeni şifre validation ve hata mesajlarını çevir. -- [ ] Session revoke davranışını açık ve erişilebilir biçimde göster. -- [ ] Success/error toast'larını tek kaynak üzerinden üret. +- [x] Şifre değiştirme formunu taşı. +- [x] Mevcut/yeni şifre validation ve hata mesajlarını çevir. +- [x] Session revoke davranışını açık ve erişilebilir biçimde göster. +- [x] Success/error toast'larını tek kaynak üzerinden üret. Çıkış kriteri: -- [ ] Güvenlik akışı TR/EN ve custom fallback locale ile çalışıyor. +- [x] Güvenlik akışı TR/EN ve custom fallback locale ile çalışıyor. ### Faz 7 — Ayarlar / Yapay zekâ sayfası Route: `/settings/ai` -- [ ] Provider, model ve API key kontrollerini taşı. -- [ ] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle. -- [ ] AI provider ve bağlantı hata metinlerini çevir. -- [ ] Owner-only authorization'ı doğrula. +- [x] Provider, model ve API key kontrollerini taşı. +- [x] Secret mask, mevcut key, değiştirme ve hata UX'ini düzenle. +- [x] AI provider ve bağlantı hata metinlerini çevir. +- [x] Owner-only authorization'ı doğrula. Çıkış kriteri: -- [ ] AI ayarlarında sabit Türkçe/İngilizce metin yok. +- [x] AI ayarlarında sabit Türkçe/İngilizce metin yok. ### Faz 8 — Ayarlar / Kişisel dil tercihi sayfası Route: `/settings/language` -- [ ] Yalnız aktif dilleri göster. -- [ ] Kişisel tercih ile instance default bilgisini ayrı göster. -- [ ] Preference mutation'ı uygula ve layout'u güvenli yenile. -- [ ] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle. -- [ ] Dil adlarını native name + locale code ile göster. +- [x] Yalnız aktif dilleri göster. +- [x] Kişisel tercih ile instance default bilgisini ayrı göster. +- [x] Preference mutation'ı uygula ve layout'u güvenli yenile. +- [x] Arşivlenmiş preference için fallback ve yeniden seçim uyarısı ekle. +- [x] Dil adlarını native name + locale code ile göster. Çıkış kriteri: -- [ ] Owner dili yalnız bu sayfadan değiştirilebiliyor. -- [ ] Login/auth ekranlarında dil seçici bulunmuyor. +- [x] Owner dili yalnız bu sayfadan değiştirilebiliyor. +- [x] Login/auth ekranlarında dil seçici bulunmuyor. ### Faz 9 — Ayarlar / Diller liste sayfası Route: `/settings/languages` -- [ ] Yeni dil yönetim listesi UX'ini uygula. -- [ ] Default, status, fallback, completion ve usage sütunlarını ekle. -- [ ] Instance default locale kontrolünü bu sayfaya taşı. -- [ ] Draft/active/archived filtrelerini ekle. -- [ ] Loading, empty, error ve permission state'lerini çevir. +- [x] Yeni dil yönetim listesi UX'ini uygula. +- [x] Default, status, fallback, completion ve usage sütunlarını ekle. +- [x] Instance default locale kontrolünü bu sayfaya taşı. +- [x] Draft/active/archived filtrelerini ekle. +- [x] Loading, empty, error ve permission state'lerini çevir. Çıkış kriteri: -- [ ] Dil listesi çeviri editörüyle aynı ekranda değil. -- [ ] Default dil ve kişisel dil tercihi birbirine karışmıyor. +- [x] Dil listesi çeviri editörüyle aynı ekranda değil. +- [x] Default dil ve kişisel dil tercihi birbirine karışmıyor. ### Faz 10 — Ayarlar / Yeni dil sayfası Route: `/settings/languages/new` -- [ ] BCP 47 locale formunu uygula. -- [ ] Native ad, yönetim adı, direction ve fallback alanlarını ekle. -- [ ] Custom dilin daima draft oluşmasını sağla. -- [ ] Duplicate, self-fallback ve fallback-loop hatalarını çevir. -- [ ] Başarılı kayıtta dil detayına yönlendir. +- [x] BCP 47 locale formunu uygula. +- [x] Native ad, yönetim adı, direction ve fallback alanlarını ekle. +- [x] Custom dilin daima draft oluşmasını sağla. +- [x] Duplicate, self-fallback ve fallback-loop hatalarını çevir. +- [x] Başarılı kayıtta dil detayına yönlendir. Çıkış kriteri: -- [ ] Geçersiz locale DB'ye ulaşmadan reddediliyor. +- [x] Geçersiz locale DB'ye ulaşmadan reddediliyor. ### Faz 11 — Ayarlar / Dil detay sayfası Route: `/settings/languages/[locale]` -- [ ] Metadata ve lifecycle yönetimini uygula. -- [ ] Namespace/sayfa bazlı completion özetini ekle. -- [ ] Kullanıcı/client kullanım etkisini göster. -- [ ] Activate/default/archive readiness kontrollerini uygula. -- [ ] Built-in korumalarını UI ve service katmanında test et. +- [x] Metadata ve lifecycle yönetimini uygula. +- [x] Namespace/sayfa bazlı completion özetini ekle. +- [x] Kullanıcı/client kullanım etkisini göster. +- [x] Activate/default/archive readiness kontrollerini uygula. +- [x] Built-in korumalarını UI ve service katmanında test et. Çıkış kriteri: -- [ ] Eksik kritik çevirili draft dil aktif edilemiyor. +- [x] Eksik kritik çevirili draft dil aktif edilemiyor. ### Faz 12 — Ayarlar / Çeviri editörü diff --git a/package.json b/package.json index 7251b7a..6ca30ed 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,9 @@ "i18n:phase7-smoke": "node scripts/i18n-phase7-smoke.mjs", "i18n:phase8-smoke": "node scripts/i18n-phase8-smoke.mjs", "phase-i18n:boundary": "node scripts/i18n-phase9-boundary.mjs", - "i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs" + "i18n:phase9-hardening": "node scripts/i18n-phase9-hardening.mjs", + "i18n:v2-page-audit": "node scripts/i18n-v2-page-audit.mjs", + "i18n:v2-phase1-smoke": "node scripts/i18n-v2-phase1-smoke.mjs" }, "dependencies": { "@ai-sdk/google": "^3.0.80", diff --git a/scripts/i18n-phase1-smoke.mjs b/scripts/i18n-phase1-smoke.mjs index c770d80..b4a1a6a 100644 --- a/scripts/i18n-phase1-smoke.mjs +++ b/scripts/i18n-phase1-smoke.mjs @@ -74,11 +74,12 @@ function assertMigratedDatabase(dbPath, expectedDefaultLocale) { ], "Built-in locales must be seeded", ); - assert.deepEqual( - sqlite.prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings").all(), - [{ key: "default", defaultLocale: expectedDefaultLocale, catalogVersion: 1 }], - "Default i18n settings must be seeded", - ); + const i18nSettings = sqlite + .prepare("select key, default_locale as defaultLocale, catalog_version as catalogVersion from instance_i18n_settings") + .get(); + assert.equal(i18nSettings.key, "default", "Default i18n settings must be seeded"); + assert.equal(i18nSettings.defaultLocale, expectedDefaultLocale, "Default locale must survive backup"); + assert.ok(i18nSettings.catalogVersion >= 1, "Catalog version must be positive"); const clientColumns = sqlite.prepare("pragma table_info(clients)").all().map((column) => column.name); assert.equal(clientColumns.includes("portal_locale"), true, "clients.portal_locale must exist"); diff --git a/scripts/i18n-phase1-smoke.ts b/scripts/i18n-phase1-smoke.ts index b9c21d0..3f8a5a4 100644 --- a/scripts/i18n-phase1-smoke.ts +++ b/scripts/i18n-phase1-smoke.ts @@ -4,7 +4,10 @@ import { drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "../server/db/schema"; import type { DomainActor } from "../server/domain/actor"; import { DomainError } from "../server/domain/errors"; -import { I18nService } from "../server/i18n/service"; +import { + ACTIVATION_CRITICAL_KEYS, + I18nService, +} from "../server/i18n/service"; const databasePath = process.argv[2]; assert.ok(databasePath, "Database path is required"); @@ -84,6 +87,7 @@ try { service.createLocale(owner, { code: "es", name: "Spanish", fallbackLocale: "fr" }); assertDomainError(() => service.updateLocale(owner, "fr", { fallbackLocale: "es" }), "VALIDATION_ERROR"); assertDomainError(() => service.setDefaultLocale(owner, "fr"), "VALIDATION_ERROR"); + completeCriticalTranslations(service, "fr"); const activeFrench = service.updateLocale(owner, "fr", { status: "active" }); assert.equal(activeFrench.status, "active"); @@ -91,13 +95,18 @@ try { assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT"); assertDomainError(() => service.archiveLocale(owner, "en"), "CONFLICT"); - service.createLocale(owner, { + const italian = service.createLocale(owner, { code: "it", name: "Italian", nativeName: "Italiano", status: "active", fallbackLocale: "en", }); + assert.equal(italian.status, "draft", "Custom locales must always start as draft"); + assert.equal(service.getLocaleReadiness(owner, "it").canActivate, false); + completeCriticalTranslations(service, "it"); + assert.equal(service.getLocaleReadiness(owner, "it").canActivate, true); + service.updateLocale(owner, "it", { status: "active" }); db.insert(schema.clients).values({ id: "i18n-client", ownerUserId: owner.authUserId, @@ -106,6 +115,10 @@ try { portalLocale: "it", }).run(); assertDomainError(() => service.archiveLocale(owner, "it"), "CONFLICT"); + assert.ok( + service.getNamespaceCompletion(owner, "it").some((item) => item.namespace === "portal"), + ); + assert.equal(service.getLocaleUsage(owner, "it").clients, 1); db.insert(schema.contentTranslations).values({ entityType: "project", @@ -130,3 +143,15 @@ try { function assertDomainError(run: () => unknown, code: DomainError["code"]): void { assert.throws(run, (error) => error instanceof DomainError && error.code === code); } + +function completeCriticalTranslations(service: I18nService, locale: string) { + for (const fullKey of ACTIVATION_CRITICAL_KEYS) { + const [namespace, ...keyParts] = fullKey.split("."); + service.upsertUiTranslation(owner, { + locale, + namespace, + key: keyParts.join("."), + value: `${locale}:${fullKey}`, + }); + } +} diff --git a/scripts/i18n-phase3-smoke.ts b/scripts/i18n-phase3-smoke.ts index deb9c27..5205fa4 100644 --- a/scripts/i18n-phase3-smoke.ts +++ b/scripts/i18n-phase3-smoke.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { getSqliteConnection } from "../server/db/client"; import type { DomainActor } from "../server/domain/actor"; import { DomainError } from "../server/domain/errors"; -import { I18nService } from "../server/i18n/service"; +import { + ACTIVATION_CRITICAL_KEYS, + I18nService, +} from "../server/i18n/service"; const owner: DomainActor = { authUserId: "phase3-owner", @@ -61,6 +64,8 @@ assert.equal( true, ); +assertDomainError(() => service.updateLocale(owner, "fr", { status: "active" }), "VALIDATION_ERROR"); +completeCriticalTranslations(service, "fr"); service.updateLocale(owner, "fr", { status: "active" }); assert.equal(service.setDefaultLocale(owner, "fr").defaultLocale, "fr"); assertDomainError(() => service.archiveLocale(owner, "fr"), "CONFLICT"); @@ -70,3 +75,15 @@ console.log("I18n phase 3 settings smoke passed."); function assertDomainError(run: () => unknown, code: DomainError["code"]): void { assert.throws(run, (error) => error instanceof DomainError && error.code === code); } + +function completeCriticalTranslations(service: I18nService, locale: string) { + for (const fullKey of ACTIVATION_CRITICAL_KEYS) { + const [namespace, ...keyParts] = fullKey.split("."); + service.upsertUiTranslation(owner, { + locale, + namespace, + key: keyParts.join("."), + value: `${locale}:${fullKey}`, + }); + } +} diff --git a/scripts/i18n-phase5-smoke.ts b/scripts/i18n-phase5-smoke.ts index c0866b5..cf81a06 100644 --- a/scripts/i18n-phase5-smoke.ts +++ b/scripts/i18n-phase5-smoke.ts @@ -4,7 +4,10 @@ import { getSqliteConnection } from "../server/db/client"; import type { DomainActor } from "../server/domain/actor"; import { DomainService } from "../server/services/domain"; import { ContentTranslationService } from "../server/i18n/content"; -import { I18nService } from "../server/i18n/service"; +import { + ACTIVATION_CRITICAL_KEYS, + I18nService, +} from "../server/i18n/service"; const { db } = getSqliteConnection(); const owner: DomainActor = { @@ -34,11 +37,18 @@ if (!i18n.listLocales(owner).some((locale) => locale.code === "fr")) { name: "French", nativeName: "Français", fallbackLocale: "en", - status: "active", }); -} else { - i18n.updateLocale(owner, "fr", { status: "active" }); } +for (const fullKey of ACTIVATION_CRITICAL_KEYS) { + const [namespace, ...keyParts] = fullKey.split("."); + i18n.upsertUiTranslation(owner, { + locale: "fr", + namespace, + key: keyParts.join("."), + value: `fr:${fullKey}`, + }); +} +i18n.updateLocale(owner, "fr", { status: "active" }); const domain = new DomainService(db, (() => { let next = 0; diff --git a/scripts/i18n-phase7-smoke.ts b/scripts/i18n-phase7-smoke.ts index 2c65da3..4edb125 100644 --- a/scripts/i18n-phase7-smoke.ts +++ b/scripts/i18n-phase7-smoke.ts @@ -56,8 +56,10 @@ assert.equal(loginActions.includes("E-posta veya"), false, "login action must no const inviteActions = fs.readFileSync(path.join(process.cwd(), "app", "invite", "[token]", "actions.ts"), "utf8"); assert.equal(inviteActions.includes("message=${encodeURIComponent"), false, "invite action redirects must use stable codes"); -const localeApi = fs.readFileSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts"), "utf8"); -assert.equal(localeApi.includes("Dil kodu"), false, "locale API must not embed Turkish validation messages"); -assert.equal(localeApi.includes("messageKey"), true, "locale API must expose messageKey for localized clients"); +assert.equal( + fs.existsSync(path.join(process.cwd(), "app", "api", "i18n", "locale", "route.ts")), + false, + "The obsolete locale-cookie API must remain removed", +); console.log("I18n phase 7 auth/error/a11y smoke passed."); diff --git a/scripts/i18n-v2-page-audit.mjs b/scripts/i18n-v2-page-audit.mjs new file mode 100644 index 0000000..307b4a5 --- /dev/null +++ b/scripts/i18n-v2-page-audit.mjs @@ -0,0 +1,243 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const expectedPages = [ + "app/(dashboard)/page.tsx", + "app/(dashboard)/analytics/page.tsx", + "app/(dashboard)/calendar/page.tsx", + "app/(dashboard)/chat/page.tsx", + "app/(dashboard)/clients/page.tsx", + "app/(dashboard)/clients/[id]/page.tsx", + "app/(dashboard)/finance/page.tsx", + "app/(dashboard)/journal/page.tsx", + "app/(dashboard)/projects/page.tsx", + "app/(dashboard)/projects/[id]/page.tsx", + "app/(dashboard)/tasks/page.tsx", + "app/(dashboard)/business/invoices/page.tsx", + "app/(dashboard)/business/proposals/page.tsx", + "app/(dashboard)/business/subscriptions/page.tsx", + "app/login/page.tsx", + "app/register/page.tsx", + "app/forgot-password/page.tsx", + "app/reset-password/page.tsx", + "app/invite/[token]/page.tsx", + "app/portal/page.tsx", + "app/portal/projects/page.tsx", + "app/portal/projects/[id]/page.tsx", + "app/portal/revisions/page.tsx", + "app/portal/tasks/page.tsx", +]; +const authPages = [ + "app/login/page.tsx", + "app/register/page.tsx", + "app/forgot-password/page.tsx", + "app/reset-password/page.tsx", +]; +const settingsPages = [ + "app/(dashboard)/settings/general/page.tsx", + "app/(dashboard)/settings/appearance/page.tsx", + "app/(dashboard)/settings/profile/page.tsx", + "app/(dashboard)/settings/security/page.tsx", + "app/(dashboard)/settings/ai/page.tsx", + "app/(dashboard)/settings/language/page.tsx", + "app/(dashboard)/settings/languages/page.tsx", + "app/(dashboard)/settings/languages/new/page.tsx", + "app/(dashboard)/settings/languages/[locale]/page.tsx", +]; +const completedSettingsPageFiles = [ + "app/(dashboard)/settings/general/page.tsx", + "app/(dashboard)/settings/general/general-settings-form.tsx", + "app/(dashboard)/settings/general/actions.ts", + "app/(dashboard)/settings/appearance/page.tsx", + "app/(dashboard)/settings/appearance/appearance-settings-form.tsx", + "app/(dashboard)/settings/appearance/actions.ts", + "app/(dashboard)/settings/profile/page.tsx", + "app/(dashboard)/settings/profile/profile-settings-form.tsx", + "app/(dashboard)/settings/profile/actions.ts", + "app/(dashboard)/settings/security/page.tsx", + "app/(dashboard)/settings/security/security-settings-form.tsx", + "app/(dashboard)/settings/security/actions.ts", + "app/(dashboard)/settings/ai/page.tsx", + "app/(dashboard)/settings/ai/ai-settings-form.tsx", + "app/(dashboard)/settings/ai/actions.ts", + "app/(dashboard)/settings/language/page.tsx", + "app/(dashboard)/settings/language/language-preference-form.tsx", + "app/(dashboard)/settings/language/actions.ts", + "app/(dashboard)/settings/languages/page.tsx", + "app/(dashboard)/settings/languages/languages-list.tsx", + "app/(dashboard)/settings/languages/actions.ts", + "app/(dashboard)/settings/languages/new/page.tsx", + "app/(dashboard)/settings/languages/new/new-language-form.tsx", + "app/(dashboard)/settings/languages/new/actions.ts", + "app/(dashboard)/settings/languages/[locale]/page.tsx", + "app/(dashboard)/settings/languages/[locale]/language-detail.tsx", + "app/(dashboard)/settings/languages/[locale]/actions.ts", +]; + +for (const file of [...expectedPages, ...settingsPages]) { + assert.ok(fs.existsSync(path.join(root, file)), `Missing planned page: ${file}`); +} + +for (const file of authPages) { + const source = read(file); + assert.ok(source.includes("resolvePublicLocale"), `${file} must use the public locale resolver`); + assert.ok(!source.includes("LocaleSelectForm"), `${file} must not expose a locale selector`); +} + +for (const file of completedSettingsPageFiles) { + const source = read(file); + assert.ok( + !/[ÇĞİÖŞÜçğıöşü]/.test(source), + `${file} must not contain hard-coded Turkish user-facing text`, + ); + assert.ok( + !source.includes('from "../settings-content"'), + `${file} must not depend on the transitional settings monolith`, + ); +} +for (const route of ["general", "appearance", "profile", "security", "ai", "language"]) { + assert.ok( + fs.existsSync(path.join(root, `app/(dashboard)/settings/${route}/actions.ts`)), + `${route} settings must have a route-specific server action boundary`, + ); +} +const generalPage = read("app/(dashboard)/settings/general/page.tsx"); +assert.ok( + generalPage.includes('locale.status === "active"'), + "General settings must render content tabs for active locales only", +); +const appearancePage = read("app/(dashboard)/settings/appearance/appearance-settings-form.tsx"); +for (const marker of ["applyColorMode", "sm:grid-cols-3", "lightLogo", "darkLogo", "favicon"]) { + assert.ok(appearancePage.includes(marker), `Appearance regression marker is missing: ${marker}`); +} +const appearanceActions = read("app/(dashboard)/settings/appearance/actions.ts"); +for (const marker of ["deleteSupersededBrandingFiles", "deleteBrandingFilesBestEffort", "requireFreelancerBackend"]) { + assert.ok(appearanceActions.includes(marker), `Appearance action safety marker is missing: ${marker}`); +} +const securityActions = read("app/(dashboard)/settings/security/actions.ts"); +for (const marker of ["changePassword", "revokeOtherSessions: true", "errorKey"]) { + assert.ok(securityActions.includes(marker), `Security action marker is missing: ${marker}`); +} +const aiPage = read("app/(dashboard)/settings/ai/page.tsx"); +const aiForm = read("app/(dashboard)/settings/ai/ai-settings-form.tsx"); +const aiService = read("server/settings/ai.ts"); +assert.ok(aiPage.includes("getPublicAiSettings"), "AI settings must use owner-scoped public settings"); +assert.ok(!aiPage.includes("apiKey:"), "AI settings page must never send a secret to the client"); +for (const marker of ["model", "hasApiKey", "apiKey.masked"]) { + assert.ok(aiForm.includes(marker), `AI settings UX marker is missing: ${marker}`); +} +assert.ok(aiService.includes("model,"), "AI model selection must be persisted"); +const languagePage = read("app/(dashboard)/settings/language/page.tsx"); +const languageForm = read("app/(dashboard)/settings/language/language-preference-form.tsx"); +assert.ok( + languagePage.includes('locale.status === "active"'), + "Language preference must list active locales only", +); +for (const marker of ["nativeName", "defaultLocale", "preferenceNeedsSelection", "router.refresh()"]) { + assert.ok(languageForm.includes(marker), `Language preference marker is missing: ${marker}`); +} +assert.ok( + !fs.existsSync(path.join(root, "app/(dashboard)/settings/settings-content.tsx")), + "The transitional settings monolith must be removed", +); +assert.ok( + !fs.existsSync(path.join(root, "app/(dashboard)/settings/actions.ts")), + "The obsolete shared settings action boundary must be removed", +); +const languagesList = read("app/(dashboard)/settings/languages/languages-list.tsx"); +for (const marker of ["completion", "usage", "makeDefault", "archived"]) { + assert.ok(languagesList.includes(marker), `Language list marker is missing: ${marker}`); +} +const languageNewAction = read("app/(dashboard)/settings/languages/new/actions.ts"); +for (const marker of ["Intl.getCanonicalLocales", "SUPPORTED_BCP47_PATTERN", "createLocale"]) { + assert.ok(languageNewAction.includes(marker), `New language marker is missing: ${marker}`); +} +const languageDetail = read("app/(dashboard)/settings/languages/[locale]/language-detail.tsx"); +for (const marker of ["namespaceCompletion", "readiness", "usage", "DestructiveConfirmation"]) { + assert.ok(languageDetail.includes(marker), `Language detail marker is missing: ${marker}`); +} +const i18nService = read("server/i18n/service.ts"); +assert.ok( + i18nService.includes('status: "draft"'), + "Custom languages must always be created as draft", +); +for (const marker of ["assertLocaleCanBeActivated", "ACTIVATION_CRITICAL_KEYS", "getLocaleReadiness"]) { + assert.ok(i18nService.includes(marker), `Language lifecycle service marker is missing: ${marker}`); +} + +const resolver = read("server/i18n/resolver.ts"); +for (const exportName of [ + "resolvePublicLocale", + "resolveInvitationLocale", + "resolveFreelancerLocale", + "resolvePortalLocale", +]) { + assert.ok(resolver.includes(exportName), `Resolver export is missing: ${exportName}`); +} +assert.ok(!resolver.includes("LOCALE_COOKIE"), "Server locale authority must not depend on the locale cookie"); +assert.ok( + !fs.existsSync(path.join(root, "app/api/i18n/locale/route.ts")), + "The obsolete locale-cookie mutation route must not exist", +); +assert.ok( + !fs.existsSync(path.join(root, "components/i18n/locale-select-form.tsx")), + "The obsolete public locale selector must not exist", +); + +const settingsNavigation = read("app/(dashboard)/settings/settings-navigation.tsx"); +assert.ok(settingsNavigation.includes("md:sticky"), "Settings navigation must be sticky on desktop"); +assert.ok(settingsNavigation.includes("usePathname"), "Settings navigation must be route-aware"); +for (const boundary of ["loading.tsx", "error.tsx", "not-found.tsx"]) { + assert.ok( + fs.existsSync(path.join(root, "app/(dashboard)/settings", boundary)), + `Settings boundary is missing: ${boundary}`, + ); +} + +const invitationService = read("server/auth/invitations.ts"); +const clientLocaleMutation = invitationService.slice( + invitationService.indexOf("export function setClientPortalLocale"), +); +assert.ok( + !clientLocaleMutation.includes("tx.insert(userPreferences)"), + "Updating the admin-assigned client locale must preserve the client's personal preference", +); + +const userFacingFiles = listFiles(["app", "components", "config"]) + .filter((file) => /\.(ts|tsx)$/.test(file)); +const hardCodedCandidates = userFacingFiles + .map((file) => ({ + file, + count: (read(file).match(/[ÇĞİÖŞÜçğıöşü]/g) ?? []).length, + })) + .filter((entry) => entry.count > 0) + .sort((left, right) => right.count - left.count); + +console.log(`I18n V2 page audit passed: ${expectedPages.length + settingsPages.length} page routes accounted for.`); +console.log(`Baseline hard-coded Turkish-character candidates: ${hardCodedCandidates.length} files.`); +for (const entry of hardCodedCandidates.slice(0, 12)) { + console.log(` ${entry.count.toString().padStart(4)} ${entry.file}`); +} + +function read(file) { + return fs.readFileSync(path.join(root, file), "utf8"); +} + +function listFiles(roots) { + const result = []; + for (const rootName of roots) { + walk(path.join(root, rootName), rootName); + } + return result; + + function walk(absolute, relative) { + for (const entry of fs.readdirSync(absolute, { withFileTypes: true })) { + const entryAbsolute = path.join(absolute, entry.name); + const entryRelative = path.join(relative, entry.name); + if (entry.isDirectory()) walk(entryAbsolute, entryRelative); + else result.push(entryRelative); + } + } +} diff --git a/scripts/i18n-v2-phase1-smoke.mjs b/scripts/i18n-v2-phase1-smoke.mjs new file mode 100644 index 0000000..9d1c3b2 --- /dev/null +++ b/scripts/i18n-v2-phase1-smoke.mjs @@ -0,0 +1,20 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; + +const root = process.cwd(); +const outputRoot = path.join(root, ".next", "i18n-v2-phase1-smoke-dist"); + +execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-v2-phase1-smoke.json"], { + cwd: root, + stdio: "inherit", +}); + +const packageFile = path.join(outputRoot, "package.json"); +fs.writeFileSync(packageFile, '{"type":"commonjs"}\n'); + +execFileSync( + process.execPath, + [path.join(outputRoot, "scripts", "i18n-v2-phase1-smoke.js")], + { cwd: root, stdio: "inherit" }, +); diff --git a/scripts/i18n-v2-phase1-smoke.ts b/scripts/i18n-v2-phase1-smoke.ts new file mode 100644 index 0000000..0b7ca0f --- /dev/null +++ b/scripts/i18n-v2-phase1-smoke.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { resolveLocalePolicy } from "../lib/i18n/locale-resolution"; + +const locales = [ + { code: "tr", status: "active", textDirection: "ltr" }, + { code: "en", status: "active", textDirection: "ltr" }, + { code: "fr", status: "active", textDirection: "ltr" }, + { code: "de", status: "archived", textDirection: "ltr" }, + { code: "ar", status: "active", textDirection: "rtl" }, +] as const; + +const publicLocale = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "en", + candidates: [], +}); +assert.equal(publicLocale.locale, "en"); +assert.equal(publicLocale.source, "instance"); + +const freelancerLocale = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [{ locale: "en", source: "user" }], +}); +assert.equal(freelancerLocale.locale, "en"); +assert.equal(freelancerLocale.source, "user"); + +const portalPreference = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [ + { locale: "en", source: "user" }, + { locale: "fr", source: "client" }, + ], +}); +assert.equal(portalPreference.locale, "en", "Personal preference must win over the client default"); + +const portalDefault = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [ + { locale: null, source: "user" }, + { locale: "fr", source: "client" }, + ], +}); +assert.equal(portalDefault.locale, "fr"); +assert.equal(portalDefault.source, "client"); + +const invitationLocale = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [{ locale: "fr", source: "invitation" }], +}); +assert.equal(invitationLocale.locale, "fr"); +assert.equal(invitationLocale.source, "invitation"); + +const archivedPreference = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [{ locale: "de", source: "user" }], +}); +assert.equal(archivedPreference.locale, "tr"); +assert.equal(archivedPreference.source, "instance"); + +const invalidDefault = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "de", + candidates: [], +}); +assert.equal(invalidDefault.locale, "tr"); +assert.equal(invalidDefault.source, "fallback"); + +const rtlLocale = resolveLocalePolicy({ + activeLocales: locales, + defaultLocale: "tr", + candidates: [{ locale: "ar", source: "user" }], +}); +assert.equal(rtlLocale.direction, "rtl"); + +console.log("I18n V2 phase 1 locale policy smoke passed."); diff --git a/tsconfig.i18n-v2-phase1-smoke.json b/tsconfig.i18n-v2-phase1-smoke.json new file mode 100644 index 0000000..f61634b --- /dev/null +++ b/tsconfig.i18n-v2-phase1-smoke.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.phase2-smoke.json", + "compilerOptions": { + "outDir": ".next/i18n-v2-phase1-smoke-dist" + }, + "include": [ + "scripts/i18n-v2-phase1-smoke.ts", + "lib/i18n/locale-resolution.ts" + ], + "exclude": ["node_modules"] +}