Skip to content

Commit 7d2fefb

Browse files
ericyangpanclaude
andcommitted
docs(i18n): add i18n architecture rules and validation scripts
- Add comprehensive I18N_ARCHITECTURE_RULES.md with organization principles - Add translation duplicate analysis script - Add i18n validation script with locale checking - Add metadata registry for page configuration - Add required fields validation module - Remove obsolete claude commands Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 2bcaf96 commit 7d2fefb

File tree

8 files changed

+1226
-25
lines changed

8 files changed

+1226
-25
lines changed

.claude/commands/commit-all.md

Lines changed: 0 additions & 9 deletions
This file was deleted.

.claude/commands/commit.md

Lines changed: 0 additions & 8 deletions
This file was deleted.

docs/I18N-ARCHITECTURE-RULES.md

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ translations/
6969
└── ...
7070
```
7171

72+
**Note**: Every page translation file created under this rule **MUST include a `meta` section** as specified in Rule 4.
73+
7274
**Rationale**:
7375
- Better file organization and maintainability
7476
- Easier to locate translations for specific pages
@@ -188,9 +190,14 @@ src/components/
188190

189191
---
190192

191-
### Rule 4: Metadata Placement
193+
### Rule 4: Required Metadata Section
194+
195+
**Principle**: **Every page translation file MUST include a `meta` section** containing page metadata (meta title, description, keywords, OG tags). This is a mandatory requirement for all page translation files.
192196

193-
**Principle**: Page metadata (meta title, description, keywords, OG tags) should be co-located with page translations.
197+
**Requirement**:
198+
-**Required**: Every `pages/*.json` file must have a `meta` object
199+
-**Required**: The `meta` object must be present even if initially empty: `"meta": {}`
200+
-**Required**: All locales must include the `meta` section (can use English placeholders initially per Rule 5)
194201

195202
**Implementation**:
196203
```json
@@ -206,10 +213,23 @@ src/components/
206213
}
207214
```
208215

216+
**Minimum Required Structure**:
217+
Even if metadata is not yet defined, the file must include an empty meta object:
218+
```json
219+
{
220+
"meta": {},
221+
"title": "Page Title",
222+
"...": "..."
223+
}
224+
```
225+
209226
**Rationale**:
210-
- Single source of truth for page content
211-
- Easier to maintain consistency between visible content and metadata
212-
- Aligns with Next.js metadata generation patterns
227+
- **Consistency**: Ensures all pages follow the same structure
228+
- **Discoverability**: Makes it easy to identify which pages have metadata defined
229+
- **Type Safety**: Enables consistent type definitions across all page translations
230+
- **Single source of truth**: Co-locates page content and metadata
231+
- **Maintainability**: Easier to maintain consistency between visible content and metadata
232+
- **Next.js alignment**: Aligns with Next.js metadata generation patterns
213233

214234
---
215235

@@ -311,9 +331,11 @@ To align the current codebase with these rules:
311331
- [ ] Remove @:shared references where code can use tShared
312332
- [ ] Update component code to use both tPage/tComponent and tShared
313333

314-
### Phase 4: Standardize metadata
315-
- [ ] Ensure all pages have meta objects
316-
- [ ] Verify metadata localization completeness
334+
### Phase 4: Standardize metadata (MANDATORY)
335+
- [ ] **Audit all page translation files** - Verify every `pages/*.json` file includes a `meta` section
336+
- [ ] **Add missing metadata sections** - For any page without `meta`, add at minimum `"meta": {}`
337+
- [ ] **Populate metadata** - Fill in title, description, and keywords for all pages
338+
- [ ] **Verify metadata localization** - Ensure all locales have metadata sections (can use English placeholders initially)
317339

318340
### Phase 5: Establish translation workflow
319341
- [ ] Document the English placeholder → translation workflow
@@ -492,4 +514,6 @@ These rules establish a clear, scalable architecture for i18n translations that:
492514
- Reduces redundancy by minimizing cross-namespace @: references
493515
- Co-locates related content (metadata with page translations)
494516

517+
**Critical Requirement**: Every page translation file (`pages/*.json`) **MUST include a `meta` section**, even if initially empty. This is a mandatory structural requirement that ensures consistency and enables proper metadata management across all pages.
518+
495519
All future translation work should follow these guidelines.
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Script to analyze duplicate keys and values in translation files
4+
* Scans all JSON files in translations/en/ directory and reports:
5+
* - Duplicate keys (same key path in multiple files)
6+
* - Duplicate values (same value used for different keys)
7+
*/
8+
9+
import fs from 'node:fs'
10+
import path from 'node:path'
11+
import { fileURLToPath } from 'node:url'
12+
13+
const __filename = fileURLToPath(import.meta.url)
14+
const __dirname = path.dirname(__filename)
15+
const ROOT_DIR = path.resolve(__dirname, '../..')
16+
const TRANSLATIONS_DIR = path.join(ROOT_DIR, 'translations', 'en')
17+
18+
/**
19+
* Flatten a nested object into dot-notation keys
20+
* @param {object} obj - The object to flatten
21+
* @param {string} prefix - The prefix for keys
22+
* @returns {object} - Flattened object with dot-notation keys
23+
*/
24+
function flattenObject(obj, prefix = '') {
25+
const flattened = {}
26+
27+
for (const [key, value] of Object.entries(obj)) {
28+
const newKey = prefix ? `${prefix}.${key}` : key
29+
30+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
31+
// Recursively flatten nested objects
32+
Object.assign(flattened, flattenObject(value, newKey))
33+
} else {
34+
// Store the value
35+
flattened[newKey] = value
36+
}
37+
}
38+
39+
return flattened
40+
}
41+
42+
/**
43+
* Read and parse a JSON file
44+
* @param {string} filePath - Path to the JSON file
45+
* @returns {object|null} - Parsed JSON object or null if error
46+
*/
47+
function readJsonFile(filePath) {
48+
try {
49+
const content = fs.readFileSync(filePath, 'utf8')
50+
return JSON.parse(content)
51+
} catch (error) {
52+
console.warn(`Warning: Failed to read ${filePath}: ${error.message}`)
53+
return null
54+
}
55+
}
56+
57+
/**
58+
* Get all JSON files recursively from a directory
59+
* @param {string} dir - Directory to search
60+
* @param {string} baseDir - Base directory for relative paths
61+
* @returns {Array<{filePath: string, relativePath: string}>} - Array of file info
62+
*/
63+
function getAllJsonFiles(dir, baseDir = dir) {
64+
const files = []
65+
66+
if (!fs.existsSync(dir)) {
67+
return files
68+
}
69+
70+
const entries = fs.readdirSync(dir, { withFileTypes: true })
71+
72+
for (const entry of entries) {
73+
const fullPath = path.join(dir, entry.name)
74+
const relativePath = path.relative(baseDir, fullPath)
75+
76+
if (entry.isDirectory()) {
77+
// Recursively search subdirectories
78+
files.push(...getAllJsonFiles(fullPath, baseDir))
79+
} else if (entry.isFile() && entry.name.endsWith('.json')) {
80+
files.push({
81+
filePath: fullPath,
82+
relativePath: relativePath,
83+
})
84+
}
85+
}
86+
87+
return files
88+
}
89+
90+
/**
91+
* Analyze all translation files
92+
* @returns {object} - Analysis results
93+
*/
94+
function analyzeTranslations() {
95+
const files = getAllJsonFiles(TRANSLATIONS_DIR)
96+
const keyMap = new Map() // key -> Array of {file, fullKey}
97+
const valueMap = new Map() // value -> Array of {file, fullKey}
98+
99+
console.log(`Scanning ${files.length} translation files...\n`)
100+
101+
// Process each file
102+
for (const { filePath, relativePath } of files) {
103+
const data = readJsonFile(filePath)
104+
if (!data) continue
105+
106+
const flattened = flattenObject(data)
107+
108+
// Track keys
109+
for (const [fullKey, value] of Object.entries(flattened)) {
110+
// Track duplicate keys
111+
if (!keyMap.has(fullKey)) {
112+
keyMap.set(fullKey, [])
113+
}
114+
keyMap.get(fullKey).push({ file: relativePath, fullKey })
115+
116+
// Track duplicate values (only for string values)
117+
if (typeof value === 'string') {
118+
if (!valueMap.has(value)) {
119+
valueMap.set(value, [])
120+
}
121+
valueMap.get(value).push({ file: relativePath, fullKey })
122+
}
123+
}
124+
}
125+
126+
// Find duplicate keys (keys that appear in multiple files)
127+
const duplicateKeys = []
128+
for (const [key, locations] of keyMap.entries()) {
129+
if (locations.length > 1) {
130+
duplicateKeys.push({ key, locations })
131+
}
132+
}
133+
134+
// Find duplicate values (values used by multiple keys)
135+
const duplicateValues = []
136+
for (const [value, locations] of valueMap.entries()) {
137+
if (locations.length > 1) {
138+
duplicateValues.push({ value, locations })
139+
}
140+
}
141+
142+
return {
143+
totalFiles: files.length,
144+
totalKeys: keyMap.size,
145+
duplicateKeys,
146+
duplicateValues,
147+
}
148+
}
149+
150+
/**
151+
* Generate and print report
152+
*/
153+
function printReport(results) {
154+
const { totalFiles, totalKeys, duplicateKeys, duplicateValues } = results
155+
156+
console.log('='.repeat(80))
157+
console.log('TRANSLATION DUPLICATE ANALYSIS REPORT')
158+
console.log('='.repeat(80))
159+
console.log()
160+
161+
// Summary
162+
console.log('SUMMARY')
163+
console.log('-'.repeat(80))
164+
console.log(`Total files scanned: ${totalFiles}`)
165+
console.log(`Total unique keys: ${totalKeys}`)
166+
console.log(`Duplicate keys (same key in multiple files): ${duplicateKeys.length}`)
167+
console.log(`Duplicate values (same value for different keys): ${duplicateValues.length}`)
168+
console.log()
169+
170+
// Duplicate keys report
171+
if (duplicateKeys.length > 0) {
172+
console.log('='.repeat(80))
173+
console.log('DUPLICATE KEYS')
174+
console.log('='.repeat(80))
175+
console.log('The following keys appear in multiple files:')
176+
console.log()
177+
178+
// Sort by key name for better readability
179+
duplicateKeys.sort((a, b) => a.key.localeCompare(b.key))
180+
181+
for (const { key, locations } of duplicateKeys) {
182+
console.log(`Key: "${key}"`)
183+
console.log(` Found in ${locations.length} file(s):`)
184+
for (const { file } of locations) {
185+
console.log(` - ${file}`)
186+
}
187+
console.log()
188+
}
189+
} else {
190+
console.log('='.repeat(80))
191+
console.log('DUPLICATE KEYS')
192+
console.log('='.repeat(80))
193+
console.log('✓ No duplicate keys found (each key appears in only one file)')
194+
console.log()
195+
}
196+
197+
// Duplicate values report
198+
if (duplicateValues.length > 0) {
199+
console.log('='.repeat(80))
200+
console.log('DUPLICATE VALUES')
201+
console.log('='.repeat(80))
202+
console.log('The following values are used by multiple keys:')
203+
console.log()
204+
205+
// Sort by number of occurrences (descending) for better readability
206+
duplicateValues.sort((a, b) => b.locations.length - a.locations.length)
207+
208+
for (const { value, locations } of duplicateValues) {
209+
// Truncate long values for display
210+
const displayValue = value.length > 60 ? `${value.substring(0, 60)}...` : value
211+
console.log(`Value: "${displayValue}"`)
212+
console.log(` Used by ${locations.length} key(s):`)
213+
for (const { file, fullKey } of locations) {
214+
console.log(` - ${file} -> "${fullKey}"`)
215+
}
216+
console.log()
217+
}
218+
} else {
219+
console.log('='.repeat(80))
220+
console.log('DUPLICATE VALUES')
221+
console.log('='.repeat(80))
222+
console.log('✓ No duplicate values found (each value is unique)')
223+
console.log()
224+
}
225+
226+
console.log('='.repeat(80))
227+
console.log('END OF REPORT')
228+
console.log('='.repeat(80))
229+
}
230+
231+
/**
232+
* Main function
233+
*/
234+
function main() {
235+
if (!fs.existsSync(TRANSLATIONS_DIR)) {
236+
console.error(`Error: Translations directory not found: ${TRANSLATIONS_DIR}`)
237+
process.exit(1)
238+
}
239+
240+
const results = analyzeTranslations()
241+
printReport(results)
242+
243+
// Exit with non-zero code if duplicates found
244+
if (results.duplicateKeys.length > 0 || results.duplicateValues.length > 0) {
245+
process.exit(1)
246+
}
247+
}
248+
249+
// Run the script
250+
main()

scripts/validate/validate-i18n.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Validate i18n translations for metadata
4+
* Run with: npm run validate:i18n
5+
*/
6+
7+
import {
8+
getTranslationStats,
9+
validateAllPageTranslations,
10+
} from '../../src/lib/metadata/i18n-validation'
11+
12+
async function main() {
13+
console.log('🔍 Starting i18n translation validation...\n')
14+
15+
// Show statistics
16+
const stats = getTranslationStats()
17+
console.log('📊 Translation Statistics:')
18+
console.log(` Total pages: ${stats.totalPages}`)
19+
console.log(` Total locales: ${stats.totalLocales}`)
20+
console.log(` Expected translation files: ${stats.expectedTranslationFiles}`)
21+
console.log(`\n Pages by type:`)
22+
Object.entries(stats.pagesByType).forEach(([type, count]) => {
23+
console.log(` - ${type}: ${count}`)
24+
})
25+
console.log('')
26+
27+
// Validate all pages
28+
const errorCount = await validateAllPageTranslations()
29+
30+
// Exit with error code if there are errors
31+
if (errorCount > 0) {
32+
console.error(`\n❌ Validation failed with ${errorCount} errors`)
33+
process.exit(1)
34+
} else {
35+
console.log('\n✅ All translations are valid!')
36+
process.exit(0)
37+
}
38+
}
39+
40+
main().catch(err => {
41+
console.error('Fatal error:', err)
42+
process.exit(1)
43+
})

0 commit comments

Comments
 (0)