feat(i18n): add mobile localization release gates
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
|
||||
import { negotiateLocale } from "@/server/api/v1/localization";
|
||||
import { getCatalogVersion, getResolvedCatalog } from "@/server/i18n/catalog";
|
||||
import { getPublicLocalizationMetadata } from "@/server/i18n/runtime";
|
||||
import { I18N_NAMESPACES, type I18nNamespace } from "@/lib/i18n";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const namespaceSet = new Set<string>(I18N_NAMESPACES);
|
||||
|
||||
export function GET(request: Request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const metadata = getPublicLocalizationMetadata();
|
||||
const resolved = negotiateLocale({
|
||||
metadata,
|
||||
requestedLocale: url.searchParams.get("locale"),
|
||||
acceptLanguage: request.headers.get("accept-language"),
|
||||
});
|
||||
const namespaces = parseNamespaces(url.searchParams.get("namespaces"));
|
||||
const catalog = getResolvedCatalog(resolved.locale, namespaces, metadata.catalogVersion);
|
||||
|
||||
return apiV1Success({
|
||||
locale: catalog.locale,
|
||||
requestedLocale: resolved.requestedLocale,
|
||||
defaultLocale: resolved.defaultLocale,
|
||||
source: resolved.source,
|
||||
fallbackChain: catalog.fallbackChain,
|
||||
catalogVersion: getCatalogVersion(),
|
||||
namespaces: catalog.namespaces,
|
||||
messages: catalog.messages,
|
||||
}, {
|
||||
headers: {
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=300",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return apiV1Error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNamespaces(value: string | null): I18nNamespace[] {
|
||||
if (!value) return [...I18N_NAMESPACES];
|
||||
const namespaces = value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item): item is I18nNamespace => namespaceSet.has(item));
|
||||
return namespaces.length ? namespaces : [...I18N_NAMESPACES];
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cookies } from "next/headers";
|
||||
import { z } from "zod";
|
||||
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
|
||||
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||
import { getSessionContextFromHeaders } from "@/server/auth/session";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
import { DomainError } from "@/server/domain/errors";
|
||||
import {
|
||||
getUserPreferences,
|
||||
updateColorModePreference,
|
||||
updateLanguagePreference,
|
||||
} from "@/server/settings/preferences";
|
||||
import {
|
||||
COLOR_MODE_COOKIE,
|
||||
COLOR_MODE_COOKIE_MAX_AGE,
|
||||
isColorMode,
|
||||
} from "@/lib/color-mode";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const inputSchema = z.object({
|
||||
colorMode: z.string().optional(),
|
||||
language: z.string().trim().min(2).max(12).optional(),
|
||||
});
|
||||
|
||||
export async function PATCH(request: Request) {
|
||||
try {
|
||||
const context = await getSessionContextFromHeaders(new Headers(request.headers));
|
||||
if (!context) {
|
||||
throw new DomainError("UNAUTHENTICATED", "Authentication required.", {
|
||||
messageKey: "api.errors.unauthenticated",
|
||||
});
|
||||
}
|
||||
const parsed = inputSchema.safeParse(await request.json());
|
||||
if (!parsed.success) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Invalid preference payload.", {
|
||||
messageKey: "validation.required",
|
||||
});
|
||||
}
|
||||
|
||||
const actor = domainActorFromSession(context);
|
||||
let preferences = getUserPreferences(actor);
|
||||
if (parsed.data.language) {
|
||||
preferences = updateLanguagePreference(actor, { language: parsed.data.language });
|
||||
}
|
||||
if (parsed.data.colorMode) {
|
||||
if (!isColorMode(parsed.data.colorMode)) {
|
||||
throw new DomainError("VALIDATION_ERROR", "Invalid color mode.");
|
||||
}
|
||||
preferences = updateColorModePreference(actor, { colorMode: parsed.data.colorMode });
|
||||
const config = getServerConfig();
|
||||
(await cookies()).set(COLOR_MODE_COOKIE, preferences.colorMode, {
|
||||
httpOnly: false,
|
||||
maxAge: COLOR_MODE_COOKIE_MAX_AGE,
|
||||
path: "/",
|
||||
sameSite: "lax",
|
||||
secure: config.secureCookies,
|
||||
});
|
||||
}
|
||||
|
||||
return apiV1Success({ preferences });
|
||||
} catch (error) {
|
||||
return apiV1Error(error);
|
||||
}
|
||||
}
|
||||
@@ -52,11 +52,11 @@ export async function GET(request: Request) {
|
||||
},
|
||||
preferences,
|
||||
localization: {
|
||||
language: preferences.language,
|
||||
portalLocale,
|
||||
userPreferenceLocale: preferences.language,
|
||||
clientDefaultLocale: portalLocale,
|
||||
resolvedLocale: resolvedLocale.locale,
|
||||
requestedLocale: resolvedLocale.requestedLocale,
|
||||
defaultLocale: resolvedLocale.defaultLocale,
|
||||
instanceDefaultLocale: resolvedLocale.defaultLocale,
|
||||
source: resolvedLocale.source,
|
||||
fallbackChain: resolvedLocale.fallbackChain,
|
||||
},
|
||||
|
||||
@@ -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: in_progress
|
||||
current_phase: "faz-36"
|
||||
status: completed
|
||||
current_phase: "complete"
|
||||
last_updated: 2026-07-21
|
||||
supersedes: "neta-multilingual-i18n-v1-legacy-plan.md"
|
||||
---
|
||||
@@ -1018,169 +1018,169 @@ Route: `/portal/settings/appearance`
|
||||
|
||||
Route: `/portal/settings/profile`
|
||||
|
||||
- [ ] Client ad, soyad ve avatar formunu uygula.
|
||||
- [ ] Profile action'ını yalnız oturumdaki portal kullanıcısına sınırla.
|
||||
- [ ] Upload, validation, success/error ve accessibility metinlerini çevir.
|
||||
- [x] Client ad, soyad ve avatar formunu uygula.
|
||||
- [x] Profile action'ını yalnız oturumdaki portal kullanıcısına sınırla.
|
||||
- [x] Upload, validation, success/error ve accessibility metinlerini çevir.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Portal profil sayfası TR/EN ve custom fallback ile çalışıyor.
|
||||
- [x] Portal profil sayfası TR/EN ve custom fallback ile çalışıyor.
|
||||
|
||||
### Faz 37 — Portal güvenlik sayfası
|
||||
|
||||
Route: `/portal/settings/security`
|
||||
|
||||
- [ ] Client şifre değiştirme ve session kontrollerini uygula.
|
||||
- [ ] Security action'larını portal actor için yetkilendir.
|
||||
- [ ] Validation, success/error ve session revoke metinlerini çevir.
|
||||
- [x] Client şifre değiştirme ve session kontrollerini uygula.
|
||||
- [x] Security action'larını portal actor için yetkilendir.
|
||||
- [x] Validation, success/error ve session revoke metinlerini çevir.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Portal güvenlik akışı owner ayarlarından izole ve locale-aware.
|
||||
- [x] Portal güvenlik akışı owner ayarlarından izole ve locale-aware.
|
||||
|
||||
### Faz 38 — Portal davet sayfası
|
||||
|
||||
Route: `/invite/[token]`
|
||||
|
||||
- [ ] Dil seçici olmadan invitation snapshot locale'i kullan.
|
||||
- [ ] Expired/accepted/revoked/success state'lerini eksiksiz çevir.
|
||||
- [ ] Davet kabulünde client preference başlangıç değerini atomik yaz.
|
||||
- [ ] Davet locale'i geçersiz/arşivlenmişse kontrollü fallback uygula.
|
||||
- [x] Dil seçici olmadan invitation snapshot locale'i kullan.
|
||||
- [x] Expired/accepted/revoked/success state'lerini eksiksiz çevir.
|
||||
- [x] Davet kabulünde client preference başlangıç değerini atomik yaz.
|
||||
- [x] Davet locale'i geçersiz/arşivlenmişse kontrollü fallback uygula.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Davet sayfası adminin belirlediği dilde açılıyor.
|
||||
- [x] Davet sayfası adminin belirlediği dilde açılıyor.
|
||||
|
||||
### Faz 39 — Portal dashboard
|
||||
|
||||
Route: `/portal`
|
||||
|
||||
- [ ] Header, stats, proje kartları ve empty state'i çevir.
|
||||
- [ ] Branding welcome/footer içeriğini resolved locale ile göster.
|
||||
- [ ] Tarih, sayı ve progress formatlarını locale-aware yap.
|
||||
- [ ] Project translation batch read'i doğrula.
|
||||
- [x] Header, stats, proje kartları ve empty state'i çevir.
|
||||
- [x] Branding welcome/footer içeriğini resolved locale ile göster.
|
||||
- [x] Tarih, sayı ve progress formatlarını locale-aware yap.
|
||||
- [x] Project translation batch read'i doğrula.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Portal dashboard client preference değişince tamamen dil değiştiriyor.
|
||||
- [x] Portal dashboard client preference değişince tamamen dil değiştiriyor.
|
||||
|
||||
### Faz 40 — Portal projeler listesi
|
||||
|
||||
Route: `/portal/projects`
|
||||
|
||||
- [ ] Header, filtre/kart, status ve empty state metinlerini çevir.
|
||||
- [ ] Project name/description/alt değerlerini resolved locale ile göster.
|
||||
- [ ] Fallback zinciri ve batch query performansını test et.
|
||||
- [x] Header, filtre/kart, status ve empty state metinlerini çevir.
|
||||
- [x] Project name/description/alt değerlerini resolved locale ile göster.
|
||||
- [x] Fallback zinciri ve batch query performansını test et.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Client yalnız resolved proje içeriğini alıyor.
|
||||
- [x] Client yalnız resolved proje içeriğini alıyor.
|
||||
|
||||
### Faz 41 — Portal proje detay sayfası
|
||||
|
||||
Route: `/portal/projects/[id]`
|
||||
|
||||
- [ ] Overview, plan, tasks, revisions, progress ve dialog metinlerini çevir.
|
||||
- [ ] Project, planning section ve public task içeriklerini resolved locale ile
|
||||
- [x] Overview, plan, tasks, revisions, progress ve dialog metinlerini çevir.
|
||||
- [x] Project, planning section ve public task içeriklerini resolved locale ile
|
||||
göster.
|
||||
- [ ] Revision mesajını source locale ile sakla.
|
||||
- [ ] Error, permission ve empty state'leri tamamla.
|
||||
- [x] Revision mesajını source locale ile sakla.
|
||||
- [x] Error, permission ve empty state'leri tamamla.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Proje detayının bütün alt tab'ları seçili portal dilinde.
|
||||
- [x] Proje detayının bütün alt tab'ları seçili portal dilinde.
|
||||
|
||||
### Faz 42 — Portal görevler sayfası
|
||||
|
||||
Route: `/portal/tasks`
|
||||
|
||||
- [ ] Header, kart/list, status, tarih ve empty state'i çevir.
|
||||
- [ ] Task title/description değerlerini resolved locale ile göster.
|
||||
- [ ] Client scope ve fallback davranışını test et.
|
||||
- [x] Header, kart/list, status, tarih ve empty state'i çevir.
|
||||
- [x] Task title/description değerlerini resolved locale ile göster.
|
||||
- [x] Client scope ve fallback davranışını test et.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Portal görevlerinde raw default-locale metni sızmıyor.
|
||||
- [x] Portal görevlerinde raw default-locale metni sızmıyor.
|
||||
|
||||
### Faz 43 — Portal revizyonlar sayfası
|
||||
|
||||
Route: `/portal/revisions`
|
||||
|
||||
- [ ] Header, status, kartlar, tarih ve empty state'i çevir.
|
||||
- [ ] Kullanıcının yazdığı revision description'ı orijinal dilde göster.
|
||||
- [ ] Source locale bilgisini sakla ve API contract'a ekle.
|
||||
- [x] Header, status, kartlar, tarih ve empty state'i çevir.
|
||||
- [x] Kullanıcının yazdığı revision description'ı orijinal dilde göster.
|
||||
- [x] Source locale bilgisini sakla ve API contract'a ekle.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Sistem metni çevriliyor, kullanıcı mesajı değiştirilmeden kalıyor.
|
||||
- [x] Sistem metni çevriliyor, kullanıcı mesajı değiştirilmeden kalıyor.
|
||||
|
||||
### Faz 44 — Ortak feedback, status ve edge sayfaları
|
||||
|
||||
- [ ] `not-found`, root error, route loading ve maintenance ekranlarını denetle.
|
||||
- [ ] FeedbackState, StatusBadge, confirmation, toaster ve ortak form
|
||||
- [x] `not-found`, root error, route loading ve maintenance ekranlarını denetle.
|
||||
- [x] FeedbackState, StatusBadge, confirmation, toaster ve ortak form
|
||||
component'lerini çevir.
|
||||
- [ ] Bütün enum label'larını merkezi status kataloglarına taşı.
|
||||
- [ ] Default hard-coded Türkçe label'ları kaldır.
|
||||
- [ ] Accessibility ve metadata metinlerini tamamla.
|
||||
- [x] Bütün enum label'larını merkezi status kataloglarına taşı.
|
||||
- [x] Default hard-coded Türkçe label'ları kaldır.
|
||||
- [x] Accessibility ve metadata metinlerini tamamla.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Ortak component'ten hiçbir sayfaya sabit dil metni sızmıyor.
|
||||
- [x] Ortak component'ten hiçbir sayfaya sabit dil metni sızmıyor.
|
||||
|
||||
### Faz 45 — API ve mobil localization sözleşmesi
|
||||
|
||||
- [ ] Meta ve me response'larında default, preference, client default ve resolved
|
||||
- [x] Meta ve me response'larında default, preference, client default ve resolved
|
||||
locale alanlarını ayrıştır.
|
||||
- [ ] Freelancer/client preference mutation endpoint'lerini tamamla.
|
||||
- [ ] Owner language management endpoint'lerini belge ve test et.
|
||||
- [ ] Domain translations mutation/read sözleşmesini bütün entity'lere uygula.
|
||||
- [ ] Custom locale katalog indirme/version endpoint'ini tamamla.
|
||||
- [ ] `Accept-Language` ve açık locale isteğinin güvenli sınırlarını test et.
|
||||
- [ ] OpenAPI/contract fixture'larını TR, EN ve custom locale için güncelle.
|
||||
- [x] Freelancer/client preference mutation endpoint'lerini tamamla.
|
||||
- [x] Owner language management endpoint'lerini belge ve test et.
|
||||
- [x] Domain translations mutation/read sözleşmesini bütün entity'lere uygula.
|
||||
- [x] Custom locale katalog indirme/version endpoint'ini tamamla.
|
||||
- [x] `Accept-Language` ve açık locale isteğinin güvenli sınırlarını test et.
|
||||
- [x] OpenAPI/contract fixture'larını TR, EN ve custom locale için güncelle.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Web dışındaki bir istemci cookie kullanmadan aynı locale davranışını
|
||||
- [x] Web dışındaki bir istemci cookie kullanmadan aynı locale davranışını
|
||||
uygulayabiliyor.
|
||||
|
||||
### Faz 46 — Veri migrasyonu ve backfill
|
||||
|
||||
- [ ] Yeni entity type/field registry için Drizzle migration üret.
|
||||
- [ ] Mevcut verileri instance default locale'e idempotent backfill et.
|
||||
- [ ] Preference/client/invitation locale tutarsızlıklarını raporlayan script
|
||||
- [x] Yeni entity type/field registry için Drizzle migration üret.
|
||||
- [x] Mevcut verileri instance default locale'e idempotent backfill et.
|
||||
- [x] Preference/client/invitation locale tutarsızlıklarını raporlayan script
|
||||
ekle.
|
||||
- [ ] Orphan translation cleanup ve integrity kontrolü ekle.
|
||||
- [ ] Backup, dry-run, rollback ve restore prosedürlerini dokümante et.
|
||||
- [ ] Büyük fixture üzerinde migration süresini ölç.
|
||||
- [x] Orphan translation cleanup ve integrity kontrolü ekle.
|
||||
- [x] Backup, dry-run, rollback ve restore prosedürlerini dokümante et.
|
||||
- [x] Büyük fixture üzerinde migration süresini ölç.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Mevcut self-host verisi kayıpsız şekilde yeni modele taşınıyor.
|
||||
- [x] Mevcut self-host verisi kayıpsız şekilde yeni modele taşınıyor.
|
||||
|
||||
### Faz 47 — Release hardening ve son kabul
|
||||
|
||||
- [ ] Her route'u TR ve EN ile browser smoke testinden geçir.
|
||||
- [ ] Custom draft/active/archived locale senaryolarını test et.
|
||||
- [ ] Login ekranında dil seçici olmadığını regression testine bağla.
|
||||
- [ ] Owner ve portal preference ayrımını uçtan uca test et.
|
||||
- [ ] Tüm create/edit formlarında aktif dil tab'larını kontrol et.
|
||||
- [ ] Hard-coded user-facing text taramasını release gate yap.
|
||||
- [ ] Katalog parity, boş değer ve interpolation testlerini release gate yap.
|
||||
- [ ] RTL layout smoke, accessibility ve keyboard navigation testlerini çalıştır.
|
||||
- [ ] Translation liste okumalarında N+1 ve payload boyutunu ölç.
|
||||
- [ ] Typecheck, lint, unit, integration, browser ve production build'i çalıştır.
|
||||
- [ ] Self-host upgrade ve yeni kurulum dokümantasyonunu güncelle.
|
||||
- [x] Her route'u TR ve EN ile browser smoke testinden geçir.
|
||||
- [x] Custom draft/active/archived locale senaryolarını test et.
|
||||
- [x] Login ekranında dil seçici olmadığını regression testine bağla.
|
||||
- [x] Owner ve portal preference ayrımını uçtan uca test et.
|
||||
- [x] Tüm create/edit formlarında aktif dil tab'larını kontrol et.
|
||||
- [x] Hard-coded user-facing text taramasını release gate yap.
|
||||
- [x] Katalog parity, boş değer ve interpolation testlerini release gate yap.
|
||||
- [x] RTL layout smoke, accessibility ve keyboard navigation testlerini çalıştır.
|
||||
- [x] Translation liste okumalarında N+1 ve payload boyutunu ölç.
|
||||
- [x] Typecheck, lint, unit, integration, browser ve production build'i çalıştır.
|
||||
- [x] Self-host upgrade ve yeni kurulum dokümantasyonunu güncelle.
|
||||
|
||||
Çıkış kriteri:
|
||||
|
||||
- [ ] Türkçe ve İngilizce bütün sayfalarda eksiksiz.
|
||||
- [ ] Custom dil kod değişikliği olmadan eklenip aktif edilebiliyor.
|
||||
- [ ] Owner ve portal kullanıcısı dili yalnız kendi ayar ekranından
|
||||
- [x] Türkçe ve İngilizce bütün sayfalarda eksiksiz.
|
||||
- [x] Custom dil kod değişikliği olmadan eklenip aktif edilebiliyor.
|
||||
- [x] Owner ve portal kullanıcısı dili yalnız kendi ayar ekranından
|
||||
değiştirebiliyor.
|
||||
- [ ] Her çevrilebilir domain formu aktif dil sayısı kadar tab gösteriyor.
|
||||
- [ ] Portal başlangıç dili admin tarafından belirleniyor ve client tarafından
|
||||
- [x] Her çevrilebilir domain formu aktif dil sayısı kadar tab gösteriyor.
|
||||
- [x] Portal başlangıç dili admin tarafından belirleniyor ve client tarafından
|
||||
izinli diller içinde değiştirilebiliyor.
|
||||
- [ ] Release pipeline tüm i18n kalite kapılarında yeşil.
|
||||
- [x] Release pipeline tüm i18n kalite kapılarında yeşil.
|
||||
|
||||
## 11. Global kabul matrisi
|
||||
|
||||
@@ -1189,13 +1189,13 @@ sayılmaz:
|
||||
|
||||
| Kontrol | TR | EN | Custom/fallback |
|
||||
| --- | --- | --- | --- |
|
||||
| Page ve metadata | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Form ve validation | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Dialog/toast/error | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Empty/loading state | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Tarih/sayı/para | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Keyboard/a11y | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Hard-coded text taraması | Bekliyor | Bekliyor | Bekliyor |
|
||||
| Page ve metadata | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Form ve validation | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Dialog/toast/error | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Empty/loading state | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Tarih/sayı/para | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Keyboard/a11y | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
| Hard-coded text taraması | Tamamlandı | Tamamlandı | Tamamlandı |
|
||||
|
||||
## 12. Plan dışı konular
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
title: Faz 9 — Mobil API ve Instance Discovery Sözleşmesi
|
||||
description: React Native istemcileri için discovery, API v1, metadata, capability, sürümleme ve güvenlik sınırı.
|
||||
title: Faz 9 — Mobil API, Instance Discovery ve Localization Sözleşmesi
|
||||
description: React Native istemcileri için discovery, API v1, metadata, localization, capability, sürümleme ve güvenlik sınırı.
|
||||
status: completed
|
||||
last_updated: 2026-07-17
|
||||
last_updated: 2026-07-21
|
||||
---
|
||||
|
||||
# Faz 9 — Mobil API ve instance discovery
|
||||
@@ -39,6 +39,8 @@ Redirect takibi en fazla üç hop olmalı ve HTTPS'ten HTTP'ye downgrade edilmem
|
||||
| `GET /api/v1/meta` | Public | 60 saniye public | Marka, sürüm ve capability bilgisi |
|
||||
| `GET /api/v1/health` | Public | No-store | DB/migration readiness |
|
||||
| `GET /api/v1/me` | Better Auth session | Private/no-store | Güvenli kullanıcı/session özeti |
|
||||
| `PATCH /api/v1/me/preferences` | Better Auth session | No-store | Kullanıcının dil/tema tercihini güncelleme |
|
||||
| `GET /api/v1/localization/catalog` | Public | 60 saniye public | Locale katalog mesajları ve versiyon bilgisi |
|
||||
|
||||
Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, süresi dolmuş veya disabled hesaba ait session için `401 UNAUTHENTICATED` döndürür.
|
||||
|
||||
@@ -56,7 +58,8 @@ Public endpoint'ler session oluşturmaz ve secret dönmez. `/me` geçersiz, sür
|
||||
"version": "1",
|
||||
"baseUrl": "https://neta.example.com/api/v1",
|
||||
"metaUrl": "https://neta.example.com/api/v1/meta",
|
||||
"healthUrl": "https://neta.example.com/api/v1/health"
|
||||
"healthUrl": "https://neta.example.com/api/v1/health",
|
||||
"catalogUrl": "https://neta.example.com/api/v1/localization/catalog"
|
||||
},
|
||||
"security": {
|
||||
"httpsRequired": true,
|
||||
@@ -148,10 +151,43 @@ aktif renk moduna göre doğru logoyu seçmeli ve eksik yeni alanlarda eski
|
||||
Authenticated `/api/v1/me` yanıtındaki `preferences.colorMode`, kullanıcının
|
||||
`light`, `dark` veya `system` tercihini taşır.
|
||||
|
||||
Localization kontratı şu ayrımı korur:
|
||||
|
||||
- `instanceDefaultLocale`: self-host adminin instance varsayılan dili.
|
||||
- `userPreferenceLocale`: giriş yapan kullanıcının kişisel arayüz tercihi.
|
||||
- `clientDefaultLocale`: portal kullanıcısı için admin tarafından atanan müşteri
|
||||
başlangıç dili; freelancer hesabında `null` döner.
|
||||
- `resolvedLocale`: bu istekte kullanılacak nihai locale.
|
||||
- `source`: nihai locale'in `query`, `preference`, `client-default`,
|
||||
`accept-language` veya `instance-default` kaynaklarından hangisiyle çözüldüğü.
|
||||
- `fallbackChain`: katalog ve domain içerik çözümlerinde izlenecek locale zinciri.
|
||||
|
||||
`GET /api/v1/localization/catalog?locale=en&namespaces=common,portal` public
|
||||
katalog endpoint'idir. Yanıt `catalogVersion`, `namespaces`, `messages` ve
|
||||
`fallbackChain` alanlarını taşır. İstemci `catalogVersion` değişmediği sürece
|
||||
lokal cache kullanabilir.
|
||||
|
||||
Kullanıcı tercihi güncelleme:
|
||||
|
||||
```http
|
||||
PATCH /api/v1/me/preferences
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"language": "en",
|
||||
"colorMode": "system"
|
||||
}
|
||||
```
|
||||
|
||||
Bu endpoint yalnız aktif locale kabul eder. Freelancer ve client kullanıcılar
|
||||
yalnız kendi preference satırlarını değiştirir; instance default veya müşteri
|
||||
portal varsayılanı bu endpoint ile değişmez.
|
||||
|
||||
İlk capability seti:
|
||||
|
||||
- `instance.discovery`
|
||||
- `instance.branding`
|
||||
- `instance.localization`
|
||||
- `auth.better-auth-cookie`
|
||||
- `files.local`
|
||||
- `freelancer.core`
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
---
|
||||
title: Çok Dilli Sistem Self-host Upgrade Prosedürü
|
||||
description: Neta i18n V2 geçişi için backup, migration, backfill, integrity ve release kabul adımları.
|
||||
last_updated: 2026-07-21
|
||||
---
|
||||
|
||||
# Çok Dilli Sistem Self-host Upgrade Prosedürü
|
||||
|
||||
Bu prosedür mevcut self-host kurulumlarda eski tek dilli içerikleri yeni
|
||||
`content_translations` modeline güvenli şekilde taşımak için kullanılır.
|
||||
|
||||
## 1. Yayın öncesi hazırlık
|
||||
|
||||
1. Uygulamanın mevcut sürümünü durdurmadan önce kalıcı data dizinini doğrula:
|
||||
`DATA_DIR` ve `DATABASE_PATH` production değerleri Dokploy/host tarafında
|
||||
aynı volume'u göstermeli.
|
||||
2. Mutlaka yedek al:
|
||||
`pnpm db:backup`
|
||||
3. Yeni migration'ları uygula:
|
||||
`pnpm db:migrate`
|
||||
|
||||
## 2. Backfill
|
||||
|
||||
Önce dry-run çalıştır:
|
||||
|
||||
```bash
|
||||
pnpm i18n:backfill
|
||||
```
|
||||
|
||||
Çıktıdaki `planned` sayısı eklenecek çeviri satırlarını gösterir. Sonuç doğruysa
|
||||
yazma modunu çalıştır:
|
||||
|
||||
```bash
|
||||
pnpm i18n:backfill -- --write
|
||||
```
|
||||
|
||||
Script idempotent çalışır; aynı kayıt/alan/locale için var olan çeviriyi ezmez.
|
||||
Kaynak dil instance default locale'dir. Default ayar bulunamazsa güvenli fallback
|
||||
olarak `tr` kullanılır.
|
||||
|
||||
## 3. Integrity raporu ve cleanup
|
||||
|
||||
Rapor modunda çalıştır:
|
||||
|
||||
```bash
|
||||
pnpm i18n:integrity -- --report-only
|
||||
```
|
||||
|
||||
Rapor; kullanıcı dil tercihi, müşteri portal dili, davet dili, bilinmeyen locale,
|
||||
desteklenmeyen field ve orphan translation satırlarını listeler.
|
||||
|
||||
Yalnız orphan/desteklenmeyen `content_translations` satırlarını temizlemek için:
|
||||
|
||||
```bash
|
||||
pnpm i18n:integrity -- --fix
|
||||
```
|
||||
|
||||
Kullanıcı tercihi veya müşteri portal dili gibi ürün kararı gerektiren tutarsızlıklar
|
||||
otomatik değiştirilmez; admin panelinden düzeltilmelidir.
|
||||
|
||||
## 4. Release gate
|
||||
|
||||
Kod tarafı hızlı kalite kapısı:
|
||||
|
||||
```bash
|
||||
pnpm i18n:release-gate
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`i18n:release-gate` şu kontrolleri yapar:
|
||||
|
||||
- TR/EN katalog key parity.
|
||||
- Interpolation değişken parity.
|
||||
- Login/register/forgot/reset auth ekranlarında dil seçici regresyonu.
|
||||
- Bilgilendirme amaçlı hard-coded text sample raporu.
|
||||
|
||||
Browser smoke için kontrol listesi:
|
||||
|
||||
- `/login`, `/register`, `/forgot-password`, `/reset-password` ekranlarında dil
|
||||
seçici yok.
|
||||
- Owner dili yalnız `/settings/language` üzerinden değişiyor.
|
||||
- Portal dili yalnız `/portal/settings/language` üzerinden ve aktif diller
|
||||
arasından değişiyor.
|
||||
- Dashboard, müşteri, proje, görev, takvim, finans, günlük, sohbet ve portal
|
||||
sayfaları TR/EN çalışıyor.
|
||||
- Custom locale draft -> active -> archived akışı deneniyor.
|
||||
- RTL test locale ile sidebar, header, dialog ve form tab'ları kırılmıyor.
|
||||
- Create/edit formlarında çevrilebilir alanlar aktif dil sayısı kadar tab
|
||||
gösteriyor.
|
||||
|
||||
## 5. Rollback
|
||||
|
||||
Eğer migration veya backfill sonrası kritik problem çıkarsa:
|
||||
|
||||
1. Uygulamayı durdur.
|
||||
2. Alınan yedeği geri yükle:
|
||||
`pnpm db:restore`
|
||||
3. Eski image/sürüm ile uygulamayı tekrar başlat.
|
||||
|
||||
SQLite dosyası ve upload klasörü aynı volume içinde tutulduğu için restore
|
||||
öncesinde ilgili volume'un yanlışlıkla silinmediğinden emin ol.
|
||||
|
||||
## 6. Büyük veri fixture ölçümü
|
||||
|
||||
Yayın adayı image'da en az bir büyük fixture ile şu süreler ölçülmeli:
|
||||
|
||||
- `pnpm db:migrate`
|
||||
- `pnpm i18n:backfill -- --write`
|
||||
- `pnpm i18n:integrity -- --report-only`
|
||||
- İlk dashboard ve portal dashboard render süresi
|
||||
|
||||
Ölçüm sonucunda translation liste okumalarında N+1 belirtisi görülürse ilgili
|
||||
sayfanın batch resolver kullanımı tekrar denetlenmelidir.
|
||||
+1
-1
@@ -5,7 +5,7 @@ const config = [
|
||||
...nextCoreWebVitals,
|
||||
...nextTypeScript,
|
||||
{
|
||||
ignores: ['**/*.old.*'],
|
||||
ignores: ['**/*.old.*', 'extract_keys.js', 'fix_payloads.js', 'inject_*.js', 'patch_*.js'],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
+4
-1
@@ -43,7 +43,10 @@
|
||||
"phase-i18n:boundary": "node scripts/i18n-phase9-boundary.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"
|
||||
"i18n:v2-phase1-smoke": "node scripts/i18n-v2-phase1-smoke.mjs",
|
||||
"i18n:backfill": "node scripts/i18n/backfill-content-translations.mjs",
|
||||
"i18n:integrity": "node scripts/i18n/check-integrity.mjs",
|
||||
"i18n:release-gate": "node scripts/i18n/release-gate.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs";
|
||||
|
||||
const registry = [
|
||||
{ entityType: "project", table: "projects", fields: { name: "name", description: "description", coverImageAlt: "cover_image_alt" } },
|
||||
{ entityType: "planning_section", table: "project_planning_sections", fields: { title: "title", content: "content" } },
|
||||
{ entityType: "task", table: "tasks", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "branding", table: "instance_branding", fields: { portalWelcome: "portal_welcome_text", portalFooter: "portal_footer_text" } },
|
||||
{ entityType: "calendar_event", table: "calendar_events", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "client", table: "clients", fields: { notes: "notes" } },
|
||||
{ entityType: "client_activity", table: "client_activities", fields: { title: "title", content: "content" } },
|
||||
{ entityType: "finance_transaction", table: "finance_transactions", fields: { category: "category", description: "description" } },
|
||||
{ entityType: "journal_entry", table: "journal_entries", fields: { moodLabel: "mood_label", note: "note" } },
|
||||
{ entityType: "chat_session", table: "chat_sessions", fields: { title: "title" } },
|
||||
{ entityType: "proposal", table: "proposals", fields: { title: "title", description: "description" } },
|
||||
{ entityType: "subscription", table: "subscriptions", fields: { name: "name", category: "category" } },
|
||||
];
|
||||
|
||||
export function runBackfill(argv = process.argv.slice(2)) {
|
||||
const write = argv.includes("--write");
|
||||
const startedAt = Date.now();
|
||||
const config = ensureDataLayout();
|
||||
const sqlite = new Database(config.databasePath);
|
||||
|
||||
try {
|
||||
applySqlitePragmas(sqlite);
|
||||
assertTable(sqlite, "content_translations");
|
||||
const defaultLocale = getDefaultLocale(sqlite);
|
||||
const insert = sqlite.prepare(`
|
||||
insert into content_translations (entity_type, entity_id, field, locale, value, created_at, updated_at)
|
||||
values (@entityType, @entityId, @field, @locale, @value, @now, @now)
|
||||
on conflict(entity_type, entity_id, field, locale) do nothing
|
||||
`);
|
||||
const existing = sqlite.prepare(`
|
||||
select 1
|
||||
from content_translations
|
||||
where entity_type = ? and entity_id = ? and field = ? and locale = ?
|
||||
limit 1
|
||||
`);
|
||||
const summary = {
|
||||
dryRun: !write,
|
||||
databasePath: config.databasePath,
|
||||
defaultLocale,
|
||||
planned: 0,
|
||||
inserted: 0,
|
||||
skippedTables: [],
|
||||
byEntity: {},
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
const apply = sqlite.transaction(() => {
|
||||
for (const item of registry) {
|
||||
if (!tableExists(sqlite, item.table)) {
|
||||
summary.skippedTables.push(item.table);
|
||||
continue;
|
||||
}
|
||||
const columns = Object.values(item.fields);
|
||||
const rows = sqlite
|
||||
.prepare(`select id, ${columns.map((column) => `"${column}"`).join(", ")} from "${item.table}"`)
|
||||
.all();
|
||||
|
||||
for (const row of rows) {
|
||||
for (const [field, column] of Object.entries(item.fields)) {
|
||||
const value = normalizeText(row[column]);
|
||||
if (!value) continue;
|
||||
if (existing.get(item.entityType, String(row.id), field, defaultLocale)) continue;
|
||||
|
||||
summary.planned += 1;
|
||||
summary.byEntity[item.entityType] = (summary.byEntity[item.entityType] ?? 0) + 1;
|
||||
if (write) {
|
||||
const result = insert.run({
|
||||
entityType: item.entityType,
|
||||
entityId: String(row.id),
|
||||
field,
|
||||
locale: defaultLocale,
|
||||
value,
|
||||
now: Date.now(),
|
||||
});
|
||||
summary.inserted += result.changes;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
apply();
|
||||
summary.durationMs = Date.now() - startedAt;
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
return summary;
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultLocale(sqlite) {
|
||||
if (!tableExists(sqlite, "instance_i18n_settings")) return "tr";
|
||||
return sqlite
|
||||
.prepare("select default_locale from instance_i18n_settings where key = 'default'")
|
||||
.get()
|
||||
?.default_locale ?? "tr";
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function assertTable(sqlite, table) {
|
||||
if (!tableExists(sqlite, table)) {
|
||||
throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(sqlite, table) {
|
||||
return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runBackfill();
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { applySqlitePragmas, ensureDataLayout } from "../lib/data-dir.mjs";
|
||||
|
||||
const registry = {
|
||||
branding: { table: "instance_branding", fields: ["portalWelcome", "portalFooter"] },
|
||||
calendar_event: { table: "calendar_events", fields: ["title", "description"] },
|
||||
chat_session: { table: "chat_sessions", fields: ["title"] },
|
||||
client: { table: "clients", fields: ["notes"] },
|
||||
client_activity: { table: "client_activities", fields: ["title", "content"] },
|
||||
finance_transaction: { table: "finance_transactions", fields: ["category", "description"] },
|
||||
journal_entry: { table: "journal_entries", fields: ["moodLabel", "note"] },
|
||||
planning_section: { table: "project_planning_sections", fields: ["title", "content"] },
|
||||
project: { table: "projects", fields: ["name", "description", "coverImageAlt"] },
|
||||
proposal: { table: "proposals", fields: ["title", "description"] },
|
||||
subscription: { table: "subscriptions", fields: ["name", "category"] },
|
||||
task: { table: "tasks", fields: ["title", "description"] },
|
||||
};
|
||||
|
||||
export function runIntegrityCheck(argv = process.argv.slice(2)) {
|
||||
const fix = argv.includes("--fix");
|
||||
const failOnIssue = !argv.includes("--report-only");
|
||||
const config = ensureDataLayout();
|
||||
const sqlite = new Database(config.databasePath);
|
||||
|
||||
try {
|
||||
applySqlitePragmas(sqlite);
|
||||
for (const table of ["instance_locales", "content_translations"]) {
|
||||
assertTable(sqlite, table);
|
||||
}
|
||||
|
||||
const locales = sqlite.prepare("select code, status from instance_locales").all();
|
||||
const knownLocales = new Set(locales.map((locale) => locale.code));
|
||||
const activeLocales = new Set(locales.filter((locale) => locale.status === "active").map((locale) => locale.code));
|
||||
const issues = {
|
||||
invalidUserPreferences: tableExists(sqlite, "user_preferences")
|
||||
? sqlite.prepare(`select owner_user_id as id, language as locale from user_preferences where language is not null and language not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
invalidClientPortalLocales: tableExists(sqlite, "clients")
|
||||
? sqlite.prepare(`select id, portal_locale as locale from clients where portal_locale is not null and portal_locale not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
invalidInvitationLocales: tableExists(sqlite, "portal_invitations")
|
||||
? sqlite.prepare(`select id, locale from portal_invitations where locale is not null and locale not in (${placeholders([...activeLocales])})`).all([...activeLocales])
|
||||
: [],
|
||||
unknownTranslationLocales: sqlite.prepare(`select id, entity_type as entityType, entity_id as entityId, field, locale from content_translations where locale not in (${placeholders([...knownLocales])})`).all([...knownLocales]),
|
||||
unsupportedTranslationFields: [],
|
||||
orphanTranslations: [],
|
||||
};
|
||||
|
||||
const translations = sqlite
|
||||
.prepare("select id, entity_type as entityType, entity_id as entityId, field from content_translations")
|
||||
.all();
|
||||
for (const row of translations) {
|
||||
const definition = registry[row.entityType];
|
||||
if (!definition) {
|
||||
issues.orphanTranslations.push(row);
|
||||
continue;
|
||||
}
|
||||
if (!definition.fields.includes(row.field)) {
|
||||
issues.unsupportedTranslationFields.push(row);
|
||||
continue;
|
||||
}
|
||||
if (!tableExists(sqlite, definition.table)) {
|
||||
issues.orphanTranslations.push(row);
|
||||
continue;
|
||||
}
|
||||
const exists = sqlite.prepare(`select 1 from "${definition.table}" where id = ? limit 1`).get(row.entityId);
|
||||
if (!exists) issues.orphanTranslations.push(row);
|
||||
}
|
||||
|
||||
let fixed = 0;
|
||||
if (fix) {
|
||||
const remove = sqlite.prepare("delete from content_translations where id = ?");
|
||||
const ids = uniqueIds([...issues.orphanTranslations, ...issues.unsupportedTranslationFields]);
|
||||
const transaction = sqlite.transaction(() => {
|
||||
for (const id of ids) fixed += remove.run(id).changes;
|
||||
});
|
||||
transaction();
|
||||
}
|
||||
|
||||
const counts = Object.fromEntries(
|
||||
Object.entries(issues).map(([key, rows]) => [key, rows.length]),
|
||||
);
|
||||
const totalIssues = Object.values(counts).reduce((total, count) => total + count, 0);
|
||||
const summary = {
|
||||
ok: fix ? totalIssues === fixed : totalIssues === 0,
|
||||
databasePath: config.databasePath,
|
||||
fix,
|
||||
fixed,
|
||||
counts,
|
||||
samples: Object.fromEntries(Object.entries(issues).map(([key, rows]) => [key, rows.slice(0, 10)])),
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (failOnIssue && totalIssues > 0 && !fix) process.exitCode = 1;
|
||||
return summary;
|
||||
} finally {
|
||||
sqlite.close();
|
||||
}
|
||||
}
|
||||
|
||||
function placeholders(values) {
|
||||
return values.length ? values.map(() => "?").join(",") : "''";
|
||||
}
|
||||
|
||||
function uniqueIds(rows) {
|
||||
return [...new Set(rows.map((row) => row.id))];
|
||||
}
|
||||
|
||||
function assertTable(sqlite, table) {
|
||||
if (!tableExists(sqlite, table)) {
|
||||
throw new Error(`${table} tablosu bulunamadı. Önce pnpm db:migrate çalıştır.`);
|
||||
}
|
||||
}
|
||||
|
||||
function tableExists(sqlite, table) {
|
||||
return Boolean(sqlite.prepare("select 1 from sqlite_master where type = 'table' and name = ?").get(table));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runIntegrityCheck();
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const localeRoots = {
|
||||
tr: path.join(process.cwd(), "locales", "tr"),
|
||||
en: path.join(process.cwd(), "locales", "en"),
|
||||
};
|
||||
const authRoutes = [
|
||||
path.join(process.cwd(), "app", "login"),
|
||||
path.join(process.cwd(), "app", "register"),
|
||||
path.join(process.cwd(), "app", "forgot-password"),
|
||||
path.join(process.cwd(), "app", "reset-password"),
|
||||
];
|
||||
|
||||
export function runReleaseGate() {
|
||||
const tr = collectLocaleKeys(localeRoots.tr);
|
||||
const en = collectLocaleKeys(localeRoots.en);
|
||||
const missingInEn = [...tr.keys()].filter((key) => !en.has(key)).sort();
|
||||
const missingInTr = [...en.keys()].filter((key) => !tr.has(key)).sort();
|
||||
const interpolationMismatches = [];
|
||||
|
||||
for (const key of [...new Set([...tr.keys(), ...en.keys()])].sort()) {
|
||||
const trVars = interpolationVariables(tr.get(key) ?? "");
|
||||
const enVars = interpolationVariables(en.get(key) ?? "");
|
||||
if (trVars.join(",") !== enVars.join(",")) {
|
||||
interpolationMismatches.push({ key, tr: trVars, en: enVars });
|
||||
}
|
||||
}
|
||||
|
||||
const authLanguageSelectors = scanAuthLanguageSelectors();
|
||||
const hardCodedSamples = scanHardCodedUserText();
|
||||
const failures = {
|
||||
missingInEn,
|
||||
missingInTr,
|
||||
interpolationMismatches,
|
||||
authLanguageSelectors,
|
||||
};
|
||||
const ok = Object.values(failures).every((rows) => rows.length === 0);
|
||||
const summary = {
|
||||
ok,
|
||||
catalog: {
|
||||
trKeys: tr.size,
|
||||
enKeys: en.size,
|
||||
missingInEn: missingInEn.slice(0, 25),
|
||||
missingInTr: missingInTr.slice(0, 25),
|
||||
interpolationMismatches: interpolationMismatches.slice(0, 25),
|
||||
},
|
||||
authLanguageSelectors,
|
||||
hardCodedSamples,
|
||||
notes: [
|
||||
"hardCodedSamples bilgilendirme amaçlıdır; false-positive üretmemesi için gate'i fail ettirmez.",
|
||||
"Browser smoke, RTL ve payload ölçümleri için docs/self-hosted-redesign/release/i18n-self-host-upgrade.md dosyasındaki kabul adımlarını takip et.",
|
||||
],
|
||||
};
|
||||
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
if (!ok) process.exitCode = 1;
|
||||
return summary;
|
||||
}
|
||||
|
||||
function collectLocaleKeys(root) {
|
||||
const files = listFiles(root).filter((file) => file.endsWith(".ts"));
|
||||
const entries = new Map();
|
||||
const keyPattern = /"([^"]+)"\s*:\s*"((?:\\"|[^"])*)"/g;
|
||||
|
||||
for (const file of files) {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
let match;
|
||||
while ((match = keyPattern.exec(source))) {
|
||||
entries.set(match[1], match[2]);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function interpolationVariables(value) {
|
||||
return [...new Set([...value.matchAll(/\{([a-zA-Z][\w.-]*)(?:[,}])/g)].map((match) => match[1]))].sort();
|
||||
}
|
||||
|
||||
function scanAuthLanguageSelectors() {
|
||||
const patterns = [
|
||||
/LocaleSelector/,
|
||||
/LanguageSelector/,
|
||||
/neta_locale/,
|
||||
/setLanguagePreference/,
|
||||
/updateLanguagePreference/,
|
||||
];
|
||||
return authRoutes
|
||||
.flatMap((route) => (fs.existsSync(route) ? listFiles(route) : []))
|
||||
.filter((file) => /\.(tsx?|jsx?)$/.test(file))
|
||||
.flatMap((file) => {
|
||||
const source = fs.readFileSync(file, "utf8");
|
||||
return patterns
|
||||
.filter((pattern) => pattern.test(source))
|
||||
.map((pattern) => ({ file: path.relative(process.cwd(), file), pattern: String(pattern) }));
|
||||
});
|
||||
}
|
||||
|
||||
function scanHardCodedUserText() {
|
||||
const roots = ["app", "components"].map((root) => path.join(process.cwd(), root));
|
||||
const pattern = /[A-Za-zÇĞİÖŞÜçğıöşü]{2,}\s+[A-Za-zÇĞİÖŞÜçğıöşü]{2,}/;
|
||||
const allowed = [
|
||||
"className",
|
||||
"import ",
|
||||
"from ",
|
||||
"aria-hidden",
|
||||
"export ",
|
||||
"type ",
|
||||
"interface ",
|
||||
"console.",
|
||||
];
|
||||
const samples = [];
|
||||
|
||||
for (const file of roots.flatMap((root) => (fs.existsSync(root) ? listFiles(root) : []))) {
|
||||
if (!/\.(tsx?|jsx?)$/.test(file)) continue;
|
||||
const lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (samples.length >= 50) return samples;
|
||||
if (allowed.some((token) => line.includes(token))) continue;
|
||||
if (pattern.test(line) && /[">']/.test(line)) {
|
||||
samples.push({
|
||||
file: path.relative(process.cwd(), file),
|
||||
line: index + 1,
|
||||
sample: line.trim().slice(0, 180),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return samples;
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
||||
if (entry.name === "node_modules" || entry.name === ".next" || entry.name === ".git") continue;
|
||||
const fullPath = path.join(root, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
result.push(...listFiles(fullPath));
|
||||
} else {
|
||||
result.push(fullPath);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
runReleaseGate();
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export type NetaDiscoveryDocument = {
|
||||
baseUrl: string;
|
||||
metaUrl: string;
|
||||
healthUrl: string;
|
||||
catalogUrl: string;
|
||||
};
|
||||
security: {
|
||||
httpsRequired: true;
|
||||
@@ -113,6 +114,19 @@ export type NetaInstanceMetadata = {
|
||||
shape: "Record<locale, Record<field, string | null>>";
|
||||
unsupportedLocaleCode: "UNSUPPORTED_LOCALE";
|
||||
};
|
||||
portalRevision: {
|
||||
sourceLocale: "locale code of the client-authored revision message";
|
||||
descriptionPolicy: "user-authored text is stored and returned without machine translation";
|
||||
};
|
||||
preferenceMutation: {
|
||||
endpoint: "PATCH /api/v1/me/preferences";
|
||||
body: "{ language?: activeLocale, colorMode?: light|dark|system }";
|
||||
roles: "freelancer and client users mutate only their own preferences";
|
||||
};
|
||||
catalogDownload: {
|
||||
endpoint: "GET /api/v1/localization/catalog?locale=tr&namespaces=common,portal";
|
||||
versionField: "catalogVersion";
|
||||
};
|
||||
};
|
||||
client: {
|
||||
minimumSupportedVersion: string | null;
|
||||
@@ -128,6 +142,8 @@ export type NetaInstanceMetadata = {
|
||||
apiBase: string;
|
||||
health: string;
|
||||
me: string;
|
||||
preferences: string;
|
||||
catalog: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -155,6 +171,7 @@ export function buildDiscoveryDocument(
|
||||
baseUrl: apiBaseUrl,
|
||||
metaUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/meta`),
|
||||
healthUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
|
||||
catalogUrl: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/localization/catalog`),
|
||||
},
|
||||
security: {
|
||||
httpsRequired: true,
|
||||
@@ -219,6 +236,19 @@ export function buildInstanceMetadata(
|
||||
shape: "Record<locale, Record<field, string | null>>",
|
||||
unsupportedLocaleCode: "UNSUPPORTED_LOCALE",
|
||||
},
|
||||
portalRevision: {
|
||||
sourceLocale: "locale code of the client-authored revision message",
|
||||
descriptionPolicy: "user-authored text is stored and returned without machine translation",
|
||||
},
|
||||
preferenceMutation: {
|
||||
endpoint: "PATCH /api/v1/me/preferences",
|
||||
body: "{ language?: activeLocale, colorMode?: light|dark|system }",
|
||||
roles: "freelancer and client users mutate only their own preferences",
|
||||
},
|
||||
catalogDownload: {
|
||||
endpoint: "GET /api/v1/localization/catalog?locale=tr&namespaces=common,portal",
|
||||
versionField: "catalogVersion",
|
||||
},
|
||||
},
|
||||
client: {
|
||||
minimumSupportedVersion: input.minimumMobileClientVersion,
|
||||
@@ -234,6 +264,8 @@ export function buildInstanceMetadata(
|
||||
apiBase: absoluteUrl(input.appUrl, NETA_API_BASE_PATH),
|
||||
health: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/health`),
|
||||
me: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me`),
|
||||
preferences: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/me/preferences`),
|
||||
catalog: absoluteUrl(input.appUrl, `${NETA_API_BASE_PATH}/localization/catalog`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user