Skip to content

Commit 3338e50

Browse files
ericyangpanclaude
andcommitted
refactor(validate): rewrite i18n validation scripts with TypeScript
- Migrate validation scripts from JavaScript to TypeScript - Consolidate multiple URL validation scripts into single visit-urls.ts - Add new validation capabilities: - AST-based translation key validation (ast-parser.ts) - Namespace structure validation (namespace-validator.ts) - Translation key validator (key-validator.ts) - Add test files for validation logic: - i18n-usage.test.ts - validates translation usage patterns - manifests.i18n.test.ts - validates manifest i18n entries - Add modular reporter system (console-reporter.ts, json-reporter.ts) - Improve translation loader with better error handling This provides better type safety and more robust i18n validation. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1ea76c5 commit 3338e50

21 files changed

+2877
-545
lines changed

scripts/validate/lib/ast-parser.ts

Lines changed: 337 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,337 @@
1+
/**
2+
* AST Parser for extracting translation usage from TSX/TS files
3+
*/
4+
5+
import fs from 'node:fs'
6+
import path from 'node:path'
7+
import { parse } from '@typescript-eslint/parser'
8+
import type { TSESTree } from '@typescript-eslint/types'
9+
import type { ParsedFile, SourceLocation, TranslationCall, TranslationUsage } from './types.js'
10+
import { FileType } from './types.js'
11+
12+
/**
13+
* Determine file type based on path
14+
*/
15+
export function getFileType(filePath: string): FileType {
16+
const normalizedPath = path.normalize(filePath)
17+
18+
// Pages: src/app/[locale]/**/page*.tsx
19+
if (normalizedPath.includes('src/app/[locale]')) {
20+
return FileType.PAGE
21+
}
22+
23+
// Components: src/components/**/*.tsx
24+
if (normalizedPath.includes('src/components')) {
25+
return FileType.COMPONENT
26+
}
27+
28+
return FileType.UNKNOWN
29+
}
30+
31+
/**
32+
* Extract string literal value from a node
33+
*/
34+
function extractStringLiteral(node: TSESTree.Node): string | null {
35+
if (node.type === 'Literal') {
36+
if (typeof node.value === 'string') {
37+
return node.value
38+
}
39+
}
40+
return null
41+
}
42+
43+
/**
44+
* Check if a node is a template literal with expressions
45+
*/
46+
function isTemplateLiteralWithExpressions(node: TSESTree.Node): boolean {
47+
return node.type === 'TemplateLiteral' && node.expressions.length > 0
48+
}
49+
50+
/**
51+
* Check if a node is a binary expression (string concatenation)
52+
*/
53+
function isBinaryExpression(node: TSESTree.Node): boolean {
54+
if (node.type === 'BinaryExpression') {
55+
return true
56+
}
57+
return false
58+
}
59+
60+
/**
61+
* Determine the key type based on the AST node
62+
*/
63+
function getKeyType(node: TSESTree.CallExpressionArgument): TranslationCall['keyType'] {
64+
if (isTemplateLiteralWithExpressions(node)) {
65+
return 'dynamic'
66+
}
67+
if (isBinaryExpression(node)) {
68+
return 'dynamic'
69+
}
70+
if (node.type === 'Identifier' || node.type === 'MemberExpression') {
71+
return 'dynamic'
72+
}
73+
if (extractStringLiteral(node) !== null) {
74+
return 'static'
75+
}
76+
return 'partial'
77+
}
78+
79+
/**
80+
* Extract translation key from a call expression argument
81+
*/
82+
function extractTranslationKey(node: TSESTree.CallExpressionArgument): {
83+
key: string
84+
keyType: TranslationCall['keyType']
85+
} {
86+
const keyType = getKeyType(node)
87+
88+
if (keyType === 'static') {
89+
return { key: extractStringLiteral(node)!, keyType }
90+
}
91+
92+
if (keyType === 'dynamic') {
93+
return { key: '<dynamic>', keyType }
94+
}
95+
96+
return { key: '<unknown>', keyType }
97+
}
98+
99+
/**
100+
* Visit all nodes in the AST
101+
*/
102+
function visit(ast: TSESTree.Program, visitor: (node: TSESTree.Node) => void): void {
103+
const stack: TSESTree.Node[] = [ast]
104+
105+
while (stack.length > 0) {
106+
const node = stack.pop()!
107+
visitor(node)
108+
109+
// Push children onto stack
110+
for (const key of Object.keys(node)) {
111+
const child = node[key as keyof TSESTree.Node]
112+
if (typeof child === 'object' && child !== null) {
113+
if (Array.isArray(child)) {
114+
for (const item of child) {
115+
if (typeof item === 'object' && item !== null && 'type' in item) {
116+
stack.push(item as TSESTree.Node)
117+
}
118+
}
119+
} else if ('type' in child) {
120+
stack.push(child as TSESTree.Node)
121+
}
122+
}
123+
}
124+
}
125+
}
126+
127+
/**
128+
* Find all useTranslations declarations in the AST
129+
*/
130+
function findTranslationsDeclarations(
131+
ast: TSESTree.Program
132+
): Map<string, { namespace: string; location: SourceLocation }> {
133+
const translationsMap = new Map<string, { namespace: string; location: SourceLocation }>()
134+
135+
visit(ast, node => {
136+
// Look for: const tX = useTranslations('namespace')
137+
if (node.type === 'VariableDeclarator') {
138+
const declarator = node as TSESTree.VariableDeclarator
139+
140+
if (declarator.id.type === 'Identifier' && declarator.init) {
141+
const varName = declarator.id.name
142+
143+
// Check if it's a call expression
144+
if (declarator.init.type === 'CallExpression') {
145+
const call = declarator.init
146+
147+
// Check if callee is useTranslations
148+
if (call.callee.type === 'Identifier' && call.callee.name === 'useTranslations') {
149+
// Extract namespace argument
150+
if (call.arguments.length > 0) {
151+
const namespaceArg = call.arguments[0]
152+
if (namespaceArg) {
153+
const namespace = extractStringLiteral(namespaceArg)
154+
155+
if (namespace) {
156+
translationsMap.set(varName, {
157+
namespace,
158+
location: {
159+
file: '',
160+
line: declarator.loc?.start.line ?? 0,
161+
column: declarator.loc?.start.column ?? 0,
162+
},
163+
})
164+
}
165+
}
166+
}
167+
}
168+
}
169+
}
170+
}
171+
})
172+
173+
return translationsMap
174+
}
175+
176+
/**
177+
* Find all translation function calls in the AST
178+
*/
179+
function findTranslationCalls(
180+
ast: TSESTree.Program,
181+
translationsVars: Set<string>
182+
): Array<{
183+
varName: string
184+
key: string
185+
keyType: TranslationCall['keyType']
186+
location: SourceLocation
187+
}> {
188+
const calls: Array<{
189+
varName: string
190+
key: string
191+
keyType: TranslationCall['keyType']
192+
location: SourceLocation
193+
}> = []
194+
195+
visit(ast, node => {
196+
// Look for: tX('key') or tX('key', options)
197+
if (node.type === 'CallExpression') {
198+
const call = node as TSESTree.CallExpression
199+
200+
// Check if callee is a translation variable
201+
if (call.callee.type === 'Identifier' && translationsVars.has(call.callee.name)) {
202+
const varName = call.callee.name
203+
204+
// Extract key argument
205+
if (call.arguments.length > 0) {
206+
const keyArg = call.arguments[0]
207+
if (keyArg) {
208+
const { key, keyType } = extractTranslationKey(keyArg)
209+
210+
calls.push({
211+
varName,
212+
key,
213+
keyType,
214+
location: {
215+
file: '',
216+
line: call.loc?.start.line ?? 0,
217+
column: call.loc?.start.column ?? 0,
218+
},
219+
})
220+
}
221+
}
222+
}
223+
}
224+
})
225+
226+
return calls
227+
}
228+
229+
/**
230+
* Parse a single file and extract translation usage
231+
*/
232+
export function parseFile(filePath: string): ParsedFile {
233+
const fileType = getFileType(filePath)
234+
235+
try {
236+
const content = fs.readFileSync(filePath, 'utf-8')
237+
238+
const ast = parse(content, {
239+
sourceType: 'module',
240+
ecmaVersion: 'latest',
241+
ecmaFeatures: {
242+
jsx: true,
243+
},
244+
filePath,
245+
})
246+
247+
// Find all useTranslations declarations
248+
const translationsDeclarations = findTranslationsDeclarations(ast)
249+
const translationsVars = new Set(translationsDeclarations.keys())
250+
251+
// Find all translation calls
252+
const calls = findTranslationCalls(ast, translationsVars)
253+
254+
// Group calls by variable name
255+
const usages: TranslationUsage[] = []
256+
for (const [varName, { namespace, location }] of translationsDeclarations) {
257+
const varCalls = calls.filter(c => c.varName === varName)
258+
259+
usages.push({
260+
variableName: varName,
261+
namespace,
262+
calls: varCalls.map(c => ({
263+
key: c.key,
264+
keyType: c.keyType,
265+
location: {
266+
file: filePath,
267+
line: c.location.line,
268+
column: c.location.column,
269+
},
270+
})),
271+
location: {
272+
file: filePath,
273+
line: location.line,
274+
column: location.column,
275+
},
276+
})
277+
}
278+
279+
return {
280+
path: filePath,
281+
fileType,
282+
usages,
283+
}
284+
} catch (error) {
285+
return {
286+
path: filePath,
287+
fileType,
288+
usages: [],
289+
error: error instanceof Error ? error.message : String(error),
290+
}
291+
}
292+
}
293+
294+
/**
295+
* Find all TSX/TS files in a directory recursively
296+
*/
297+
export function findSourceFiles(dir: string, extensions: string[] = ['.tsx', '.ts']): string[] {
298+
const files: string[] = []
299+
300+
const traverse = (currentPath: string): void => {
301+
const entries = fs.readdirSync(currentPath, { withFileTypes: true })
302+
303+
for (const entry of entries) {
304+
const fullPath = path.join(currentPath, entry.name)
305+
306+
if (entry.isDirectory()) {
307+
// Skip node_modules and other common directories
308+
if (
309+
entry.name === 'node_modules' ||
310+
entry.name === '.next' ||
311+
entry.name === 'dist' ||
312+
entry.name === 'build' ||
313+
entry.name === '.git'
314+
) {
315+
continue
316+
}
317+
traverse(fullPath)
318+
} else if (entry.isFile()) {
319+
const ext = path.extname(entry.name)
320+
if (extensions.includes(ext)) {
321+
files.push(fullPath)
322+
}
323+
}
324+
}
325+
}
326+
327+
traverse(dir)
328+
329+
return files
330+
}
331+
332+
/**
333+
* Filter files by type (pages or components)
334+
*/
335+
export function filterFilesByType(files: string[], fileType: FileType): string[] {
336+
return files.filter(file => getFileType(file) === fileType)
337+
}
Lines changed: 3 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -4,37 +4,17 @@
44

55
import path from 'node:path'
66
import { fileURLToPath } from 'node:url'
7+
import { locales } from '@/i18n/config'
78

89
const __filename = fileURLToPath(import.meta.url)
910
const __dirname = path.dirname(__filename)
1011
export const ROOT_DIR = path.resolve(__dirname, '../../..')
1112

12-
// Locales configuration
13-
export const LOCALES = [
14-
'en',
15-
'de',
16-
'es',
17-
'fr',
18-
'id',
19-
'ja',
20-
'ko',
21-
'pt',
22-
'ru',
23-
'tr',
24-
'zh-Hans',
25-
'zh-Hant',
26-
]
13+
// Re-export from central i18n config
14+
export const LOCALES = locales as readonly string[]
2715

2816
// Base URL - can be overridden via BASE_URL environment variable
2917
export const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'
3018

3119
// Delay between requests in milliseconds - can be overridden via REQUEST_DELAY environment variable
32-
// Default: 100ms (0.1 second) between requests to avoid overwhelming the server
3320
export const REQUEST_DELAY = Number.parseInt(process.env.REQUEST_DELAY || '100', 10)
34-
35-
/**
36-
* Get locale prefix for URL
37-
*/
38-
export function getLocalePrefix(locale) {
39-
return locale === 'en' ? '' : `/${locale}`
40-
}

0 commit comments

Comments
 (0)