# 多è¯è¨€è¯è¡¨ä¸€è‡´æ€§æ ¡éªŒï¼šé”®é›†åˆã€å ä½ç¬¦ã€æ ¼å¼è¯´æ˜Žç¬¦
# 以基准è¯è¨€æ–‡ä»¶ï¼ˆé»˜è®¤ zh-Hans.yml)为准,所有è¯è¨€å¿…é¡»æä¾›å®Œå…¨ç›¸åŒçš„键集åˆ
#
# 用法:
# pwsh -File check-locales.ps1 # 星尘默认目录(Stardust)
# pwsh -File check-locales.ps1 -LocaleDir Stardust.Web\Locales -SourceDir Stardust.Web
# pwsh -File check-locales.ps1 -LocaleDir Stardust\Locales -SkipOrphan # ä¸‹æ¸¸åªæƒ³æ ¡éªŒè¯è¡¨å†…部一致性
# pwsh -File check-locales.ps1 -LocaleDir Stardust.Data\Locales -SourceDir Stardust.Data -MetadataKeys Stardust.Data\Locales\metadata-keys.json # 元数æ®é”®å…许部分è¯è¨€ç¼ºå¤±
param(
[String]$LocaleDir = "Stardust\Locales",
[String]$Baseline = "zh-Hans.yml",
[String[]]$SourceDir = @("Stardust"),
# 元数æ®é”®æ¸…å•,å…许部分è¯è¨€ç¼ºå¤±ï¼ˆç¼ºå¤±å›žé€€æºæ–‡ï¼‰ï¼Œå¹¶å…于å¤å„¿é”®æ£€æŸ¥
[String]$MetadataKeys,
# 从生æˆå®žä½“/控制器的 [DisplayName]/[Description] ç‰¹æ€§ä¸æ”¶é›†å…ƒæ•°æ®é”®
[String[]]$MetadataSourceDir = @(),
[Switch]$SkipOrphan,
[Switch]$Strict
)
$ErrorActionPreference = "Stop"
# pwsh -File 会把 "NewLife.Cube,NewLife.CubeNC" 当æˆå•ä¸ªå‚æ•°ï¼Œè¿™é‡Œç»Ÿä¸€æ‹†æˆå¤šä¸ªç›®å½•
$SourceDir = @($SourceDir | ForEach-Object { $_ -split '[,;]' } | Where-Object { $_ })
$root = $PSScriptRoot
$localeDir = if ([IO.Path]::IsPathRooted($LocaleDir)) { $LocaleDir } else { Join-Path $root $LocaleDir }
if (!(Test-Path $localeDir)) {
Write-Host "未找到è¯è¨€ç›®å½•:$localeDir" -ForegroundColor Red
exit 1
}
$pattern = '^\s*"((?:[^"\\]|\\.)*)"\s*:\s*"((?:[^"\\]|\\.)*)"\s*$'
$placeholder = '\{(\d+)(?:,-?\d+)?(:[^}]*)?\}'
$languageCode = '\b[a-z]{2}-[A-Za-z]{2,4}\b'
function Unescape-Yaml([string]$text) {
# 与æºç”Ÿæˆå™¨ä¿æŒä¸€è‡´ï¼šè¿˜åŽŸ \\ \" \n \r \t \uXXXX
return [regex]::Replace($text, '\\(u[0-9a-fA-F]{4}|.)', {
param($m)
$v = $m.Groups[1].Value
if ($v.Length -eq 5) { return [string][char][Convert]::ToInt32($v.Substring(1), 16) }
switch -CaseSensitive ($v) {
'n' { return "`n" }
'r' { return "`r" }
't' { return "`t" }
'\\' { return '\' }
'"' { return '"' }
default { return $m.Value }
}
})
}
function Get-Placeholders([string]$text) {
$set = [System.Collections.Generic.HashSet[int]]::new()
foreach ($m in [regex]::Matches($text, $placeholder)) {
[void]$set.Add([int]$m.Groups[1].Value)
}
return ,$set
}
function Get-PlaceholderSpecs([string]$text) {
$map = @{}
foreach ($m in [regex]::Matches($text, $placeholder)) {
$index = [int]$m.Groups[1].Value
if (!$map.ContainsKey($index)) { $map[$index] = $m.Groups[2].Value }
}
return $map
}
# è¯è¨€æ¸…啿¡ç›®ï¼šè¯‘æ–‡ä¸çš„è¯è¨€ä»£ç 集åˆå¿…须与英文 key 一致,防æ¢è¯‘者æ¼ç
function Test-LanguageCodeList([string]$key, [string]$value) {
if (!$key.StartsWith("UI language. Values:")) { return $true }
$expected = @([regex]::Matches($key, $languageCode) | ForEach-Object { $_.Value } | Sort-Object -Unique)
$actual = @([regex]::Matches($value, $languageCode) | ForEach-Object { $_.Value } | Sort-Object -Unique)
return !(Compare-Object $expected $actual)
}
function Read-Locale([string]$path) {
$entries = [ordered]@{}
$errors = @()
$lineNo = 0
foreach ($line in Get-Content -LiteralPath $path -Encoding UTF8) {
$lineNo++
if ($line.Trim().Length -eq 0 -or $line.TrimStart().StartsWith("#")) { continue }
$m = [regex]::Match($line, $pattern)
if (!$m.Success) {
$errors += "第 $lineNo è¡Œæ ¼å¼éžæ³•:$line"
continue
}
$key = Unescape-Yaml $m.Groups[1].Value
if ($entries.Contains($key)) {
$errors += "第 $lineNo 行键é‡å¤ï¼š$key"
continue
}
$entries[$key] = Unescape-Yaml $m.Groups[2].Value
}
return @{ Entries = $entries; Errors = $errors }
}
$referencePath = Join-Path $localeDir $Baseline
if (!(Test-Path -LiteralPath $referencePath)) {
Write-Host "未找到基准è¯è¨€æ–‡ä»¶ï¼š$referencePath" -ForegroundColor Red
exit 1
}
$reference = Read-Locale $referencePath
$allErrors = @($reference.Errors | ForEach-Object { "$($Baseline): $_" })
$allWarnings = @()
foreach ($key in $reference.Entries.Keys) {
if (!(Test-LanguageCodeList $key $reference.Entries[$key])) {
$allErrors += "$($Baseline): è¯è¨€ä»£ç 清å•ä¸ä¸€è‡´ → $key"
}
}
# 元数æ®é”®ï¼šæ¥è‡ªæ˜¾å¼æ¸…啿ˆ–实体/控制器特性。å…许部分è¯è¨€ç¼ºå¤±ï¼Œå¹¶å…于å¤å„¿é”®æ£€æŸ¥
$metadataKeySet = [System.Collections.Generic.HashSet[String]]::new([StringComparer]::Ordinal)
if ($MetadataKeys) {
$metaPath = if ([IO.Path]::IsPathRooted($MetadataKeys)) { $MetadataKeys } else { Join-Path $root $MetadataKeys }
if (!(Test-Path -LiteralPath $metaPath)) {
Write-Host "未找到元数æ®é”®æ¸…å•:$metaPath" -ForegroundColor Red
exit 1
}
$metaJson = Get-Content -LiteralPath $metaPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($metaJson -is [array]) { $metaItems = $metaJson }
elseif ($null -ne $metaJson.keys) { $metaItems = $metaJson.keys }
else { $metaItems = @() }
foreach ($item in @($metaItems)) { if ($item) { [void]$metadataKeySet.Add([String]$item) } }
}
foreach ($dir in @($MetadataSourceDir | Where-Object { $_ })) {
$dirPath = if ([IO.Path]::IsPathRooted($dir)) { $dir } else { Join-Path $root $dir }
if (!(Test-Path $dirPath)) { continue }
foreach ($file in Get-ChildItem $dirPath -Recurse -Filter *.cs -File) {
$text = [IO.File]::ReadAllText($file.FullName)
foreach ($m in [regex]::Matches($text, '\[(?:DisplayName|Description)\("((?:[^"\\]|\\.)*)"\)\]')) {
$key = $m.Groups[1].Value.Replace('\"', '"').Replace('\\', '\')
if ($key) { [void]$metadataKeySet.Add($key) }
}
}
}
foreach ($file in Get-ChildItem $localeDir -Filter *.yml | Sort-Object Name) {
if ($file.Name -eq $Baseline) { continue }
$lang = [IO.Path]::GetFileNameWithoutExtension($file.Name)
$locale = Read-Locale $file.FullName
foreach ($e in $locale.Errors) { $allErrors += "$($file.Name): $e" }
foreach ($key in $reference.Entries.Keys) {
# en æ˜¯æºæ–‡è¯è¨€ï¼Œå…è®¸åªæä¾›å…ƒæ•°æ®è¯‘文,其余消æ¯å›žé€€è‹±æ–‡æºæ–‡
if (!$locale.Entries.Contains($key) -and !$metadataKeySet.Contains($key) -and $lang -ne "en") {
$allErrors += "$($file.Name): 缺少键 $key"
}
}
foreach ($key in $locale.Entries.Keys) {
if (!$reference.Entries.Contains($key)) {
# 元数æ®é”®å…许åªå‡ºçŽ°åœ¨éƒ¨åˆ†è¯è¨€ï¼›ä½†ä»éœ€æ ¡éªŒå ä½ç¬¦
if ($metadataKeySet.Contains($key)) {
$value = $locale.Entries[$key]
$expected = Get-Placeholders $key
$actual = Get-Placeholders $value
if (!$expected.SetEquals($actual)) { $allErrors += "$($file.Name): å ä½ç¬¦ä¸ä¸€è‡´ → $key" }
continue
}
$allErrors += "$($file.Name): 多余键 $key"
continue
}
$value = $locale.Entries[$key]
$expected = Get-Placeholders $key
$actual = Get-Placeholders $value
if (!$expected.SetEquals($actual)) {
$allErrors += "$($file.Name): å ä½ç¬¦ä¸ä¸€è‡´ → $key"
continue
}
# æ ¼å¼è¯´æ˜Žç¬¦åªåœ¨è¯‘æ–‡ä¿ç•™è¯¥å ä½ç¬¦æ—¶æ¯”对,缺失时仅告è¦
$enSpecs = Get-PlaceholderSpecs $key
$trSpecs = Get-PlaceholderSpecs $value
foreach ($index in $enSpecs.Keys) {
if ($trSpecs.ContainsKey($index) -and $trSpecs[$index] -ne $enSpecs[$index]) {
$allWarnings += "$($file.Name): æ ¼å¼è¯´æ˜Žç¬¦ä¸ä¸€è‡´ → $key"
break
}
}
if (!(Test-LanguageCodeList $key $value)) {
$allErrors += "$($file.Name): è¯è¨€ä»£ç 清å•ä¸ä¸€è‡´ → $key"
}
}
}
# ä¸¥æ ¼æ¨¡å¼ï¼šè¯‘æ–‡ä¸å¾—ä¸Žè‹±æ–‡æºæ–‡å®Œå…¨ç›¸åŒï¼ˆå€¼ç‰äºŽé”®å³è§†ä¸ºæœªç¿»è¯‘),
# åŒå½¢è¯ï¼ˆå¦‚å¾·è¯ Statusã€è·å…°è¯ Systeem)在 Locales/identical.allow.json 䏿Œ‰è¯è¨€ç™»è®°åŽæ”¾è¡Œã€‚
if ($Strict) {
$allowPath = Join-Path $localeDir "identical.allow.json"
$allow = @{}
if (Test-Path -LiteralPath $allowPath) {
$allowJson = Get-Content -LiteralPath $allowPath -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($p in $allowJson.PSObject.Properties) {
$set = [System.Collections.Generic.HashSet[String]]::new([StringComparer]::Ordinal)
foreach ($v in @($p.Value)) { [void]$set.Add([String]$v) }
$allow[$p.Name] = $set
}
}
foreach ($file in Get-ChildItem $localeDir -Filter *.yml | Sort-Object Name) {
$lang = [IO.Path]::GetFileNameWithoutExtension($file.Name)
if ($lang -eq [IO.Path]::GetFileNameWithoutExtension($Baseline) -or $lang -eq "en") { continue }
$locale = Read-Locale $file.FullName
$allowed = $null
if ($allow.ContainsKey($lang)) { $allowed = $allow[$lang] }
$count = 0
foreach ($key in $locale.Entries.Keys) {
if ($locale.Entries[$key] -cne $key) { continue }
if ($null -ne $allowed -and $allowed.Contains($key)) { continue }
$allErrors += "$($file.Name): 未翻译(值ç‰äºŽè‹±æ–‡æºæ–‡ï¼‰â†’ $key"
if ($count++ -ge 20) { $allErrors += "$($file.Name): 未翻译æ¡ç›®è¿‡å¤šï¼Œä»…åˆ—å‡ºå‰ 20 æ¡"; break }
}
}
}
# å¤å„¿é”®ï¼šåŸºå‡†è¡¨ä¸çš„键在æºç 里完全找ä¸åˆ°ï¼ˆæ”¹æ–‡æ¡ˆåŽé—ç•™çš„æ»æ¡ç›®ï¼‰
$orphans = @()
if (!$SkipOrphan) {
$sourceDirs = @($SourceDir | ForEach-Object {
if ([IO.Path]::IsPathRooted($_)) { $_ } else { Join-Path $root $_ }
} | Where-Object { Test-Path $_ })
# 视图里的 L.T("...") åŒæ ·ä¼šå‘½ä¸è¯è¡¨ï¼Œå¤å„¿é”®æ£€æµ‹å¿…须把 .cshtml 也算进æ¥ï¼ˆ2026-10-20)
# 程åºé›†å…ƒæ•°æ®ï¼ˆAssemblyTitle/Description)与 PackageDescription 是å•值,按 Core è§„èŒƒä»¥è‹±æ–‡æºæ–‡å…¥è¡¨ï¼ˆ2026-10-21)
$sourceText = ($sourceDirs | ForEach-Object { Get-ChildItem $_ -Recurse -File | Where-Object { $_.Extension -in '.cs', '.cshtml', '.csproj' } } |
Where-Object { $_.FullName -notmatch '\\(obj|bin|Locales)\\' } |
ForEach-Object { [IO.File]::ReadAllText($_.FullName) }) -join "`n"
# NewLife.Cube 通过 <Compile Include="..\NewLife.CubeNC\..."> 链接共享æºç ,
# å…¶ä¸çš„ L.T è°ƒç”¨åŒæ ·ä¼šå‘½ä¸è¯è¡¨ï¼›å¤å„¿é”®æ£€æµ‹å¿…须把这些文件算进æ¥ï¼ˆ2026-10-20)
$linkProject = Join-Path $root "NewLife.Cube\NewLife.Cube.csproj"
if (Test-Path $linkProject) {
$csproj = [IO.File]::ReadAllText($linkProject)
$linkRoot = Join-Path $root "NewLife.CubeNC"
$linkedText = [regex]::Matches($csproj, 'Include="\.\.\\NewLife\.CubeNC\\([^"]+)"') |
ForEach-Object { Join-Path $linkRoot ($_.Groups[1].Value.Replace('\', '/')) } |
Where-Object { Test-Path $_ } |
ForEach-Object { [IO.File]::ReadAllText($_) }
if ($linkedText) { $sourceText += "`n" + ($linkedText -join "`n") }
}
# 统一æ¢è¡Œï¼Œé¿å…多行æç¤ºè¯å› CRLF 与 LF 差异被判为å¤å„¿é”®
$sourceText = $sourceText.Replace("`r`n", "`n")
foreach ($key in $reference.Entries.Keys) {
if ($metadataKeySet.Contains($key)) { continue }
# æºç ä¸çš„å—é¢é‡å½¢å¼ï¼šåˆ¶è¡¨ç¬¦å†™ä½œ \t,引å·å†™ä½œ \"ï¼›ANSI 转义在æºç ä¸å¯èƒ½æ˜¯ \e 或 \u001b
$escaped = $key.Replace('\', '\\').Replace('"', '\"').Replace("`t", '\t').Replace("`r", '\r').Replace("`n", '\n')
$variants = @($key, $escaped)
$esc = [String][Char]27
if ($key.Contains($esc)) {
$variants += $key.Replace($esc, '\e')
$variants += $key.Replace($esc, '\u001b')
$variants += $key.Replace($esc, '\x1b')
}
$found = $false
foreach ($v in $variants) {
if ($sourceText.Contains($v)) { $found = $true; break }
}
if (!$found) {
# 去掉 ANSI 颜色åºåˆ—与制表符转义åŽå†æ¯”对,é¿å… \e / \u001b 混用导致误报
$plainKey = $key.Replace($esc, '') -replace '\[[0-9;]*m', ''
$plainKey = $plainKey.Replace("`t", '\t')
$plainSource = $sourceText -replace '(\\e|\\u001b|\\x1b)\[[0-9;]*m', ''
if (!$plainSource.Contains($plainKey)) {
$orphans += $key
}
}
}
foreach ($o in $orphans) { $allErrors += "å¤å„¿é”®ï¼ˆæºç 䏿‰¾ä¸åˆ°ï¼‰ï¼š$o" }
}
foreach ($e in $allErrors) { Write-Host $e -ForegroundColor Red }
foreach ($w in $allWarnings) { Write-Host $w -ForegroundColor Yellow }
if ($allErrors.Count -gt 0) {
foreach ($o in $orphans) { Write-Host "å¤å„¿é”®ï¼ˆæºç 䏿‰¾ä¸åˆ°ï¼‰ï¼š$o" -ForegroundColor Red }
}
$count = (Get-ChildItem $localeDir -Filter *.yml).Count
if ($allErrors.Count -gt 0) {
Write-Host "多è¯è¨€è¯è¡¨æ ¡éªŒæœªé€šè¿‡ï¼š$count 个è¯è¨€ï¼Œ$($allErrors.Count) 个错误" -ForegroundColor Red
exit 1
}
Write-Host "多è¯è¨€è¯è¡¨æ ¡éªŒé€šè¿‡ï¼š$count 个è¯è¨€ï¼ŒåŸºå‡† $($reference.Entries.Keys.Count) æ¡" -ForegroundColor Green
|