transform-i18n.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. #!/usr/bin/env bun
  2. /**
  3. * Transform i18n translation files with Kilo branding
  4. *
  5. * This script handles i18n files by:
  6. * 1. Taking upstream's version as the base (to get new translation keys)
  7. * 2. Applying intelligent string replacements for Kilo branding
  8. * 3. Preserving lines marked with `// kilocode_change`
  9. *
  10. * String replacement rules:
  11. * - opencode.ai -> kilo.ai (domain)
  12. * - app.opencode.ai -> app.kilo.ai (app domain)
  13. * - OpenCode Desktop -> Kilo Desktop (desktop app name)
  14. * - OpenCode -> Kilo (product name in user-visible text)
  15. * - opencode upgrade -> kilo upgrade (CLI commands)
  16. * - npx opencode -> npx kilo (CLI invocation)
  17. * - anomalyco/opencode -> Kilo-Org/kilocode (GitHub repo)
  18. *
  19. * Preserved (not replaced):
  20. * - opencode.json (actual config filename)
  21. * - .opencode/ (actual directory name)
  22. * - Lines with `// kilocode_change`
  23. */
  24. import { $ } from "bun"
  25. import { Glob } from "bun"
  26. import { info, success, warn, debug } from "../utils/logger"
  27. import { defaultConfig } from "../utils/config"
  28. import { oursHasKilocodeChanges } from "../utils/git"
  29. export interface I18nTransformResult {
  30. file: string
  31. replacements: number
  32. preserved: number
  33. dryRun: boolean
  34. flagged?: boolean
  35. }
  36. export interface I18nTransformOptions {
  37. dryRun?: boolean
  38. verbose?: boolean
  39. patterns?: string[]
  40. }
  41. interface StringReplacement {
  42. pattern: RegExp
  43. replacement: string
  44. description: string
  45. }
  46. // Order matters! More specific patterns should come first
  47. const I18N_REPLACEMENTS: StringReplacement[] = [
  48. // GitHub repo references
  49. {
  50. pattern: /github\.com\/anomalyco\/opencode/g,
  51. replacement: "github.com/Kilo-Org/kilocode",
  52. description: "GitHub URL",
  53. },
  54. {
  55. pattern: /anomalyco\/opencode/g,
  56. replacement: "Kilo-Org/kilocode",
  57. description: "GitHub repo reference",
  58. },
  59. // Domain replacements (specific first)
  60. {
  61. pattern: /app\.opencode\.ai/g,
  62. replacement: "app.kilo.ai",
  63. description: "App domain",
  64. },
  65. {
  66. pattern: /opencode\.ai(?!\/zen)/g,
  67. replacement: "kilo.ai",
  68. description: "Main domain (excluding zen)",
  69. },
  70. // Product name (specific phrases first)
  71. {
  72. pattern: /OpenCode Desktop/g,
  73. replacement: "Kilo Desktop",
  74. description: "Desktop app name",
  75. },
  76. // CLI commands (be careful with order)
  77. {
  78. pattern: /npx opencode(?!\w)/g,
  79. replacement: "npx kilo",
  80. description: "npx command",
  81. },
  82. {
  83. pattern: /bun add opencode(?!\w)/g,
  84. replacement: "bun add kilo",
  85. description: "bun add command",
  86. },
  87. {
  88. pattern: /npm install opencode(?!\w)/g,
  89. replacement: "npm install kilo",
  90. description: "npm install command",
  91. },
  92. {
  93. pattern: /opencode upgrade(?!\w)/g,
  94. replacement: "kilo upgrade",
  95. description: "upgrade command",
  96. },
  97. {
  98. pattern: /opencode dev(?!\w)/g,
  99. replacement: "kilo dev",
  100. description: "dev command",
  101. },
  102. {
  103. pattern: /opencode serve(?!\w)/g,
  104. replacement: "kilo serve",
  105. description: "serve command",
  106. },
  107. {
  108. pattern: /opencode auth(?!\w)/g,
  109. replacement: "kilo auth",
  110. description: "auth command",
  111. },
  112. // Generic product name replacement (must come after specific patterns)
  113. // Only replace "OpenCode" when it's a standalone word (not part of opencode.json, etc.)
  114. {
  115. pattern: /\bOpenCode\b(?!\.json|\/| Zen)/g,
  116. replacement: "Kilo",
  117. description: "Product name",
  118. },
  119. // Environment variables (exclude OPENCODE_API_KEY)
  120. {
  121. pattern: /\bOPENCODE_(?!API_KEY\b)([A-Z_]+)\b/g,
  122. replacement: "KILO_$1",
  123. description: "Environment variable",
  124. },
  125. ]
  126. // Patterns that should NOT be replaced (preserved as-is)
  127. const PRESERVE_PATTERNS = [
  128. /opencode\.json/g, // Config filename
  129. /\.opencode\//g, // Directory name
  130. /\.opencode`/g, // Directory name in template strings
  131. /"\.opencode"/g, // Directory name in quotes
  132. /'\.opencode'/g, // Directory name in single quotes
  133. ]
  134. /**
  135. * Check if a line should be preserved (has kilocode_change marker)
  136. */
  137. function shouldPreserveLine(line: string): boolean {
  138. return line.includes("// kilocode_change")
  139. }
  140. /**
  141. * Apply string replacements to content, preserving kilocode_change lines
  142. */
  143. export function transformI18nContent(
  144. content: string,
  145. verbose = false,
  146. ): { result: string; replacements: number; preserved: number } {
  147. const lines = content.split("\n")
  148. const transformedLines: string[] = []
  149. let totalReplacements = 0
  150. let preservedCount = 0
  151. for (const line of lines) {
  152. // Skip lines marked with kilocode_change
  153. if (shouldPreserveLine(line)) {
  154. transformedLines.push(line)
  155. preservedCount++
  156. if (verbose) debug(`Preserved line: ${line.trim().substring(0, 50)}...`)
  157. continue
  158. }
  159. let transformedLine = line
  160. let lineReplacements = 0
  161. // Check if line contains patterns that should be preserved entirely
  162. let hasPreservePattern = false
  163. for (const pattern of PRESERVE_PATTERNS) {
  164. if (pattern.test(line)) {
  165. hasPreservePattern = true
  166. // Reset the regex lastIndex
  167. pattern.lastIndex = 0
  168. }
  169. }
  170. // Apply replacements
  171. for (const { pattern, replacement, description } of I18N_REPLACEMENTS) {
  172. // Reset lastIndex for global regexes
  173. pattern.lastIndex = 0
  174. if (pattern.test(transformedLine)) {
  175. pattern.lastIndex = 0
  176. // Special handling: if line has preserve patterns, be more careful
  177. if (hasPreservePattern) {
  178. // Only replace if the match is not part of a preserve pattern
  179. // This is a simplified check - we replace and let preserve patterns win
  180. }
  181. const before = transformedLine
  182. transformedLine = transformedLine.replace(pattern, replacement)
  183. if (before !== transformedLine) {
  184. lineReplacements++
  185. if (verbose) debug(` ${description}: "${before.trim()}" -> "${transformedLine.trim()}"`)
  186. }
  187. }
  188. }
  189. transformedLines.push(transformedLine)
  190. totalReplacements += lineReplacements
  191. }
  192. return {
  193. result: transformedLines.join("\n"),
  194. replacements: totalReplacements,
  195. preserved: preservedCount,
  196. }
  197. }
  198. /**
  199. * Transform a single i18n file
  200. */
  201. export async function transformI18nFile(
  202. filePath: string,
  203. options: I18nTransformOptions = {},
  204. ): Promise<I18nTransformResult> {
  205. const file = Bun.file(filePath)
  206. const content = await file.text()
  207. const { result, replacements, preserved } = transformI18nContent(content, options.verbose)
  208. if (replacements > 0 && !options.dryRun) {
  209. await Bun.write(filePath, result)
  210. }
  211. return {
  212. file: filePath,
  213. replacements,
  214. preserved,
  215. dryRun: options.dryRun ?? false,
  216. }
  217. }
  218. /**
  219. * Check if a file is an i18n translation file
  220. */
  221. export function isI18nFile(filePath: string, patterns?: string[]): boolean {
  222. const i18nPatterns = patterns || defaultConfig.i18nPatterns
  223. return i18nPatterns.some((pattern) => {
  224. // Convert glob pattern to regex
  225. const regex = new RegExp("^" + pattern.replace(/\./g, "\\.").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*") + "$")
  226. return regex.test(filePath)
  227. })
  228. }
  229. /**
  230. * Transform all i18n files
  231. */
  232. export async function transformAllI18n(options: I18nTransformOptions = {}): Promise<I18nTransformResult[]> {
  233. const results: I18nTransformResult[] = []
  234. const patterns = options.patterns || defaultConfig.i18nPatterns
  235. for (const pattern of patterns) {
  236. const glob = new Glob(pattern)
  237. for await (const path of glob.scan({ absolute: true })) {
  238. // Skip index.ts files (they're usually just exports)
  239. if (path.endsWith("/index.ts")) continue
  240. const result = await transformI18nFile(path, options)
  241. if (result.replacements > 0 || result.preserved > 0) {
  242. results.push(result)
  243. if (options.dryRun) {
  244. info(
  245. `[DRY-RUN] Would transform ${result.file}: ${result.replacements} replacements, ${result.preserved} preserved`,
  246. )
  247. } else if (result.replacements > 0) {
  248. success(`Transformed ${result.file}: ${result.replacements} replacements, ${result.preserved} preserved`)
  249. }
  250. }
  251. }
  252. }
  253. return results
  254. }
  255. /**
  256. * Transform i18n files that are in conflict during merge
  257. * Takes upstream version (theirs) and applies Kilo branding
  258. */
  259. export async function transformConflictedI18n(
  260. files: string[],
  261. options: I18nTransformOptions = {},
  262. ): Promise<I18nTransformResult[]> {
  263. const results: I18nTransformResult[] = []
  264. for (const file of files) {
  265. if (!isI18nFile(file)) {
  266. debug(`Skipping non-i18n file: ${file}`)
  267. continue
  268. }
  269. // If our version has kilocode_change markers, flag for manual resolution
  270. if (!options.dryRun && (await oursHasKilocodeChanges(file))) {
  271. warn(`${file} has kilocode_change markers — skipping auto-transform, needs manual resolution`)
  272. results.push({ file, replacements: 0, preserved: 0, dryRun: false, flagged: true })
  273. continue
  274. }
  275. // First, take upstream's version (theirs)
  276. if (!options.dryRun) {
  277. await $`git checkout --theirs ${file}`.quiet().nothrow()
  278. await $`git add ${file}`.quiet().nothrow()
  279. }
  280. // Then apply Kilo branding transformations
  281. const result = await transformI18nFile(file, options)
  282. results.push(result)
  283. if (options.dryRun) {
  284. info(`[DRY-RUN] Would take upstream and transform ${file}: ${result.replacements} replacements`)
  285. } else if (result.replacements > 0) {
  286. success(`Transformed ${file}: took upstream + ${result.replacements} Kilo branding replacements`)
  287. }
  288. }
  289. return results
  290. }
  291. // CLI entry point
  292. if (import.meta.main) {
  293. const args = process.argv.slice(2)
  294. const dryRun = args.includes("--dry-run")
  295. const verbose = args.includes("--verbose")
  296. const conflicted = args.includes("--conflicted")
  297. // Get specific files if provided
  298. const files = args.filter((a) => !a.startsWith("--"))
  299. if (dryRun) {
  300. info("Running in dry-run mode (no files will be modified)")
  301. }
  302. let results: I18nTransformResult[]
  303. if (conflicted && files.length > 0) {
  304. results = await transformConflictedI18n(files, { dryRun, verbose })
  305. } else if (files.length > 0) {
  306. results = []
  307. for (const file of files) {
  308. const result = await transformI18nFile(file, { dryRun, verbose })
  309. results.push(result)
  310. }
  311. } else {
  312. results = await transformAllI18n({ dryRun, verbose })
  313. }
  314. const totalReplacements = results.reduce((sum, r) => sum + r.replacements, 0)
  315. const totalPreserved = results.reduce((sum, r) => sum + r.preserved, 0)
  316. console.log()
  317. success(`Processed ${results.length} files`)
  318. info(`Total replacements: ${totalReplacements}`)
  319. info(`Total preserved lines: ${totalPreserved}`)
  320. if (dryRun) {
  321. info("Run without --dry-run to apply changes")
  322. }
  323. }