@devcoda/lokal-cli 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/lokal ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ require('../dist/index.js');
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@devcoda/lokal-cli",
3
+ "version": "1.0.1",
4
+ "description": "CLI tool for LOKAL - Automates string extraction and AI translations",
5
+ "main": "./dist/index.js",
6
+ "bin": {
7
+ "lokal": "./bin/lokal"
8
+ },
9
+ "scripts": {
10
+ "dev": "tsup --watch",
11
+ "clean": "rm -rf dist",
12
+ "lint": "echo 'Use root lint command'"
13
+ },
14
+ "dependencies": {
15
+ "lokal-core": "*",
16
+ "chalk": "^4.1.2",
17
+ "commander": "^11.1.0",
18
+ "ora": "^5.4.1"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^20.10.0",
22
+ "eslint": "^8.57.0",
23
+ "tsup": "^8.0.0",
24
+ "typescript": "^5.3.0"
25
+ },
26
+ "peerDependencies": {},
27
+ "engines": {
28
+ "node": ">=18.0.0"
29
+ }
30
+ }
@@ -0,0 +1,117 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import ora from 'ora';
6
+
7
+ interface InitOptions {
8
+ locales?: string;
9
+ defaultLocale?: string;
10
+ force?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Create the initial lokal config file and directory structure
15
+ */
16
+ export async function initCommand(options: InitOptions): Promise<void> {
17
+ const spinner = ora('Initializing LOKAL...').start();
18
+
19
+ try {
20
+ const projectRoot = process.cwd();
21
+ const configPath = path.join(projectRoot, 'lokal.config.js');
22
+ const localesDir = path.join(projectRoot, 'locales');
23
+
24
+ // Parse locales
25
+ const locales = options.locales
26
+ ? options.locales.split(',').map(l => l.trim())
27
+ : ['en'];
28
+
29
+ const defaultLocale = options.defaultLocale || locales[0];
30
+
31
+ // Check if already initialized
32
+ if (fs.existsSync(configPath) && !options.force) {
33
+ spinner.warn(chalk.yellow('LOKAL is already initialized. Use --force to reinitialize.'));
34
+ return;
35
+ }
36
+
37
+ // Create config file
38
+ const configContent = `module.exports = {
39
+ // Supported locales
40
+ locales: ${JSON.stringify(locales)},
41
+
42
+ // Default locale
43
+ defaultLocale: '${defaultLocale}',
44
+
45
+ // Function name for translations (t("key"))
46
+ functionName: 't',
47
+
48
+ // Component name for translations (<T>key</T>)
49
+ componentName: 'T',
50
+
51
+ // Source directory to scan
52
+ sourceDir: './src',
53
+
54
+ // Output directory for locale files
55
+ outputDir: './locales',
56
+
57
+ // AI Translation settings (optional)
58
+ // ai: {
59
+ // provider: 'openai', // or 'gemini'
60
+ // apiKey: process.env.OPENAI_API_KEY,
61
+ // model: 'gpt-4'
62
+ // }
63
+ };
64
+ `;
65
+
66
+ fs.writeFileSync(configPath, configContent, 'utf-8');
67
+ spinner.succeed(chalk.green(`Created ${chalk.bold('lokal.config.js')}`));
68
+
69
+ // Create locales directory with default locale
70
+ if (!fs.existsSync(localesDir)) {
71
+ fs.mkdirSync(localesDir, { recursive: true });
72
+ }
73
+
74
+ // Create default locale file
75
+ const defaultLocalePath = path.join(localesDir, `${defaultLocale}.json`);
76
+ if (!fs.existsSync(defaultLocalePath)) {
77
+ const initialData = {
78
+ _meta: {
79
+ generated: new Date().toISOString(),
80
+ description: 'Default locale file'
81
+ }
82
+ };
83
+ fs.writeFileSync(defaultLocalePath, JSON.stringify(initialData, null, 2), 'utf-8');
84
+ spinner.succeed(chalk.green(`Created ${chalk.bold(`locales/${defaultLocale}.json`)}`));
85
+ }
86
+
87
+ // Create .gitignore entry for locale files if .gitignore exists
88
+ const gitignorePath = path.join(projectRoot, '.gitignore');
89
+ if (fs.existsSync(gitignorePath)) {
90
+ const gitignoreContent = fs.readFileSync(gitignorePath, 'utf-8');
91
+ if (!gitignoreContent.includes('/locales/')) {
92
+ fs.appendFileSync(gitignorePath, '\n# LOKAL translations\nlocales/\n');
93
+ spinner.succeed(chalk.green('Updated .gitignore'));
94
+ }
95
+ }
96
+
97
+ console.log(chalk.bold('\n✓ LOKAL initialized successfully!'));
98
+ console.log(chalk.gray('\nNext steps:'));
99
+ console.log(chalk.gray(' 1. Add translation strings to your code using t("key") or <T>key</T>'));
100
+ console.log(chalk.gray(' 2. Run ') + chalk.cyan('npx lokal scan') + chalk.gray(' to extract strings'));
101
+ console.log(chalk.gray(' 3. Run ') + chalk.cyan('npx lokal translate') + chalk.gray(' to translate with AI'));
102
+
103
+ } catch (error) {
104
+ spinner.fail(chalk.red(`Failed to initialize: ${error}`));
105
+ process.exit(1);
106
+ }
107
+ }
108
+
109
+ export function registerInitCommand(program: Command): void {
110
+ program
111
+ .command('init')
112
+ .description('Initialize LOKAL in your project')
113
+ .option('-l, --locales <locales>', 'Comma-separated list of locales', 'en')
114
+ .option('-d, --default-locale <locale>', 'Default locale', 'en')
115
+ .option('-f, --force', 'Force reinitialization', false)
116
+ .action(initCommand);
117
+ }
@@ -0,0 +1,131 @@
1
+ import * as path from 'path';
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { ASTParser, ConfigLoader, FileStorage, type ExtractedString } from 'lokal-core';
6
+
7
+ interface ScanOptions {
8
+ config?: string;
9
+ output?: string;
10
+ verbose?: boolean;
11
+ }
12
+
13
+ /**
14
+ * Scan source files for translation strings and update locale files
15
+ */
16
+ export async function scanCommand(options: ScanOptions): Promise<void> {
17
+ const spinner = ora('Scanning for translation strings...').start();
18
+
19
+ try {
20
+ // Load config
21
+ const configLoader = new ConfigLoader();
22
+ let config;
23
+
24
+ if (options.config) {
25
+ config = configLoader.loadSync(options.config);
26
+ } else {
27
+ config = await configLoader.load();
28
+ }
29
+
30
+ const projectRoot = process.cwd();
31
+ const sourceDir = path.resolve(projectRoot, config.sourceDir);
32
+ const outputDir = options.output
33
+ ? path.resolve(projectRoot, options.output)
34
+ : path.resolve(projectRoot, config.outputDir);
35
+
36
+ spinner.text = `Scanning ${chalk.cyan(sourceDir)}...`;
37
+
38
+ // Create parser
39
+ const parser = new ASTParser({
40
+ filePath: sourceDir,
41
+ functionName: config.functionName,
42
+ componentName: config.componentName,
43
+ });
44
+
45
+ // Scan directory
46
+ const result = parser.scanDirectory(sourceDir);
47
+
48
+ if (result.errors.length > 0 && options.verbose) {
49
+ for (const error of result.errors) {
50
+ spinner.warn(chalk.yellow(error));
51
+ }
52
+ }
53
+
54
+ spinner.succeed(chalk.green(`Found ${chalk.bold(result.strings.length)} translation strings`));
55
+
56
+ if (result.strings.length === 0) {
57
+ spinner.info(chalk.gray('No strings found. Make sure to use t("key") or <T>key</T> in your code.'));
58
+ return;
59
+ }
60
+
61
+ // Create storage
62
+ const storage = new FileStorage(outputDir);
63
+
64
+ // Create unique key-value map
65
+ const uniqueStrings = new Map<string, ExtractedString>();
66
+ for (const str of result.strings) {
67
+ uniqueStrings.set(str.key, str);
68
+ }
69
+
70
+ // Get the default locale
71
+ const defaultLocale = config.defaultLocale;
72
+ const existingLocale = storage.loadLocale(defaultLocale);
73
+
74
+ // Merge with existing keys
75
+ let existingData: Record<string, any> = {};
76
+ if (existingLocale) {
77
+ existingData = existingLocale.data as Record<string, any>;
78
+ }
79
+
80
+ // Convert extracted strings to flat key-value object
81
+ const newData: Record<string, string> = {};
82
+ for (const [key, value] of uniqueStrings) {
83
+ // Use the key itself as the translation value for now
84
+ newData[key] = existingData[key] || value.value;
85
+ }
86
+
87
+ // Save the merged data
88
+ const mergedData = storage.mergeLocaleData(defaultLocale, newData);
89
+ storage.saveLocale(defaultLocale, mergedData);
90
+
91
+ spinner.succeed(chalk.green(`Updated ${chalk.bold(`locales/${defaultLocale}.json`)}`));
92
+
93
+ // Show sample of extracted strings
94
+ if (options.verbose) {
95
+ console.log(chalk.bold('\nExtracted strings:'));
96
+ const sampleKeys = Array.from(uniqueStrings.keys()).slice(0, 10);
97
+ for (const key of sampleKeys) {
98
+ console.log(chalk.gray(` • ${key}`));
99
+ }
100
+ if (uniqueStrings.size > 10) {
101
+ console.log(chalk.gray(` ... and ${uniqueStrings.size - 10} more`));
102
+ }
103
+ }
104
+
105
+ // Check for other locales that need translation
106
+ const locales = storage.getAvailableLocales();
107
+ const otherLocales = locales.filter(l => l !== defaultLocale);
108
+
109
+ if (otherLocales.length > 0) {
110
+ console.log(chalk.gray(`\nOther locales detected: ${otherLocales.join(', ')}`));
111
+ console.log(chalk.gray('Run ') + chalk.cyan('npx lokal translate') + chalk.gray(' to translate missing strings'));
112
+ }
113
+
114
+ } catch (error) {
115
+ spinner.fail(chalk.red(`Scan failed: ${error}`));
116
+ if (options.verbose) {
117
+ console.error(error);
118
+ }
119
+ process.exit(1);
120
+ }
121
+ }
122
+
123
+ export function registerScanCommand(program: Command): void {
124
+ program
125
+ .command('scan')
126
+ .description('Scan source files for translation strings')
127
+ .option('-c, --config <path>', 'Path to config file')
128
+ .option('-o, --output <path>', 'Output directory for locale files')
129
+ .option('-v, --verbose', 'Verbose output', false)
130
+ .action(scanCommand);
131
+ }
@@ -0,0 +1,153 @@
1
+ import * as path from 'path';
2
+ import { Command } from 'commander';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import {
6
+ ConfigLoader,
7
+ FileStorage,
8
+ AITranslator,
9
+ TranslationProviderFactory,
10
+ type LocaleData
11
+ } from 'lokal-core';
12
+
13
+ interface TranslateOptions {
14
+ config?: string;
15
+ locale?: string;
16
+ all?: boolean;
17
+ verbose?: boolean;
18
+ }
19
+
20
+ /**
21
+ * Translate missing strings using AI
22
+ */
23
+ export async function translateCommand(options: TranslateOptions): Promise<void> {
24
+ const spinner = ora('Loading configuration...').start();
25
+
26
+ try {
27
+ // Load config
28
+ const configLoader = new ConfigLoader();
29
+ let config;
30
+
31
+ if (options.config) {
32
+ config = configLoader.loadSync(options.config);
33
+ } else {
34
+ config = await configLoader.load();
35
+ }
36
+
37
+ // Check if AI is configured
38
+ if (!config.ai) {
39
+ spinner.fail(chalk.red('AI provider not configured. Add ai configuration to lokal.config.js'));
40
+ console.log(chalk.gray('\nExample configuration:'));
41
+ console.log(chalk.gray(' ai: {'));
42
+ console.log(chalk.gray(' provider: "openai",'));
43
+ console.log(chalk.gray(' apiKey: process.env.OPENAI_API_KEY'));
44
+ console.log(chalk.gray(' }'));
45
+ process.exit(1);
46
+ }
47
+
48
+ const apiKey = config.ai.apiKey || process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY;
49
+ if (!apiKey) {
50
+ spinner.fail(chalk.red('No API key found. Set ai.apiKey in config or environment variable.'));
51
+ process.exit(1);
52
+ }
53
+
54
+ spinner.text = 'Initializing AI translator...';
55
+
56
+ // Create provider
57
+ const provider = TranslationProviderFactory.create(
58
+ config.ai.provider,
59
+ apiKey,
60
+ config.ai.model
61
+ );
62
+
63
+ // Create translator
64
+ const translator = new AITranslator(provider);
65
+
66
+ const projectRoot = process.cwd();
67
+ const outputDir = path.resolve(projectRoot, config.outputDir);
68
+ const storage = new FileStorage(outputDir);
69
+
70
+ spinner.succeed('AI translator ready');
71
+
72
+ // Determine which locales to translate
73
+ let targetLocales: string[] = [];
74
+ const availableLocales = config.locales || [];
75
+
76
+ if (options.all) {
77
+ targetLocales = availableLocales.filter(l => l !== config.defaultLocale);
78
+ } else if (options.locale) {
79
+ targetLocales = [options.locale];
80
+ } else {
81
+ // Default: translate to first non-default locale
82
+ targetLocales = availableLocales.filter(l => l !== config.defaultLocale).slice(0, 1);
83
+ }
84
+
85
+ if (targetLocales.length === 0) {
86
+ spinner.warn(chalk.yellow('No target locales to translate. Add more locales to your config.'));
87
+ return;
88
+ }
89
+
90
+ // Load source locale data
91
+ const sourceLocale = config.defaultLocale;
92
+ const sourceData = storage.loadLocale(sourceLocale);
93
+
94
+ if (!sourceData) {
95
+ spinner.fail(chalk.red(`Source locale ${sourceLocale} not found. Run 'lokal scan' first.`));
96
+ process.exit(1);
97
+ }
98
+
99
+ // Translate to each target locale
100
+ for (const targetLocale of targetLocales) {
101
+ const translateSpinner = ora(`Translating to ${chalk.cyan(targetLocale)}...`).start();
102
+
103
+ // Load existing target data (if any)
104
+ const targetLocaleFile = storage.loadLocale(targetLocale);
105
+ const targetData: LocaleData = targetLocaleFile ? targetLocaleFile.data : {};
106
+
107
+ // Translate missing keys
108
+ const translatedData = await translator.translateMissingKeys(
109
+ sourceData.data,
110
+ targetData,
111
+ sourceLocale,
112
+ targetLocale
113
+ );
114
+
115
+ // Save translated data
116
+ storage.saveLocale(targetLocale, translatedData);
117
+
118
+ translateSpinner.succeed(chalk.green(`Translated to ${chalk.bold(targetLocale)}`));
119
+
120
+ if (options.verbose) {
121
+ // Show sample translations
122
+ const keys = Object.keys(translatedData).slice(0, 5);
123
+ for (const key of keys) {
124
+ const value = translatedData[key];
125
+ if (typeof value === 'string') {
126
+ console.log(chalk.gray(` ${key}: ${value}`));
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ console.log(chalk.bold('\n✓ Translation complete!'));
133
+ console.log(chalk.gray(`\nRun `) + chalk.cyan('npx lokal scan') + chalk.gray(' to see updated translations'));
134
+
135
+ } catch (error) {
136
+ spinner.fail(chalk.red(`Translation failed: ${error}`));
137
+ if (options.verbose) {
138
+ console.error(error);
139
+ }
140
+ process.exit(1);
141
+ }
142
+ }
143
+
144
+ export function registerTranslateCommand(program: Command): void {
145
+ program
146
+ .command('translate')
147
+ .description('Translate missing strings using AI')
148
+ .option('-c, --config <path>', 'Path to config file')
149
+ .option('-l, --locale <locale>', 'Specific locale to translate')
150
+ .option('-a, --all', 'Translate all locales', false)
151
+ .option('-v, --verbose', 'Verbose output', false)
152
+ .action(translateCommand);
153
+ }
package/src/index.ts ADDED
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import { registerInitCommand } from './commands/init';
6
+ import { registerScanCommand } from './commands/scan';
7
+ import { registerTranslateCommand } from './commands/translate';
8
+
9
+ const program = new Command();
10
+
11
+ program
12
+ .name('lokal')
13
+ .description('AI-powered localization ecosystem for React and React Native')
14
+ .version('1.0.0');
15
+
16
+ // Register commands
17
+ registerInitCommand(program);
18
+ registerScanCommand(program);
19
+ registerTranslateCommand(program);
20
+
21
+ // Global options
22
+ program
23
+ .option('-v, --verbose', 'Verbose output')
24
+ .hook('preAction', (thisCommand) => {
25
+ const opts = thisCommand.opts();
26
+ if (opts.verbose) {
27
+ process.env.LOKAL_VERBOSE = 'true';
28
+ }
29
+ });
30
+
31
+ // Handle unknown commands
32
+ program.on('command:*', () => {
33
+ console.error(chalk.red(`Invalid command: ${program.args.join(' ')}`));
34
+ console.log(chalk.gray(`See ${chalk.cyan('--help')} for a list of available commands.`));
35
+ process.exit(1);
36
+ });
37
+
38
+ // Run the program
39
+ program.parse(process.argv);
@@ -0,0 +1,127 @@
1
+ declare module 'lokal-core' {
2
+ export class ASTParser {
3
+ constructor(options: {
4
+ filePath: string;
5
+ functionName?: string;
6
+ componentName?: string;
7
+ });
8
+ scanDirectory(dirPath: string): ScanResult;
9
+ }
10
+
11
+ export interface ExtractedString {
12
+ key: string;
13
+ value: string;
14
+ location?: {
15
+ file: string;
16
+ line: number;
17
+ column: number;
18
+ };
19
+ }
20
+
21
+ export interface ScanResult {
22
+ strings: ExtractedString[];
23
+ errors: string[];
24
+ }
25
+
26
+ export interface ScanOptions {
27
+ filePath: string;
28
+ functionName?: string;
29
+ componentName?: string;
30
+ }
31
+
32
+ export class ConfigLoader {
33
+ load(configPath?: string): Promise<LokalConfig>;
34
+ loadSync(configPath?: string): LokalConfig;
35
+ }
36
+
37
+ export interface LokalConfig {
38
+ sourceDir: string;
39
+ outputDir: string;
40
+ defaultLocale: string;
41
+ functionName: string;
42
+ componentName: string;
43
+ locales?: string[];
44
+ ai?: {
45
+ provider: 'openai' | 'gemini';
46
+ apiKey: string;
47
+ model?: string;
48
+ };
49
+ }
50
+
51
+ export const defaultConfig: LokalConfig;
52
+
53
+ export class FileStorage {
54
+ constructor(outputDir: string);
55
+ loadLocale(locale: string): LocaleFile | null;
56
+ saveLocale(locale: string, data: LocaleData): void;
57
+ mergeLocaleData(locale: string, newData: Record<string, string>): LocaleData;
58
+ getAvailableLocales(): string[];
59
+ }
60
+
61
+ export interface LocaleData {
62
+ [key: string]: string;
63
+ }
64
+
65
+ export interface LocaleFile {
66
+ locale: string;
67
+ data: LocaleData;
68
+ }
69
+
70
+ export class ContentHasher {
71
+ static hash(content: string): string;
72
+ }
73
+
74
+ export class AITranslator {
75
+ constructor(provider: TranslationProvider, options?: {
76
+ batchSize?: number;
77
+ });
78
+ translateBatch(request: TranslationBatch): Promise<TranslationResult[]>;
79
+ translateMissingKeys(
80
+ sourceData: Record<string, string>,
81
+ targetData: Record<string, string>,
82
+ sourceLocale: string,
83
+ targetLocale: string
84
+ ): Promise<Record<string, string>>;
85
+ }
86
+
87
+ export interface TranslationProvider {
88
+ translate(request: TranslationRequest): Promise<TranslationResult>;
89
+ }
90
+
91
+ export interface TranslationRequest {
92
+ text: string;
93
+ sourceLocale: string;
94
+ targetLocale: string;
95
+ context?: string;
96
+ }
97
+
98
+ export interface TranslationResult {
99
+ translatedText: string;
100
+ success: boolean;
101
+ error?: string;
102
+ }
103
+
104
+ export interface TranslationBatch {
105
+ items: TranslationRequest[];
106
+ }
107
+
108
+ export class OpenAIProvider implements TranslationProvider {
109
+ constructor(apiKey: string, options?: {
110
+ model?: string;
111
+ temperature?: number;
112
+ });
113
+ translate(request: TranslationRequest): Promise<TranslationResult>;
114
+ }
115
+
116
+ export class GeminiProvider implements TranslationProvider {
117
+ constructor(apiKey: string, options?: {
118
+ model?: string;
119
+ temperature?: number;
120
+ });
121
+ translate(request: TranslationRequest): Promise<TranslationResult>;
122
+ }
123
+
124
+ export class TranslationProviderFactory {
125
+ static create(provider: 'openai' | 'gemini', apiKey: string, model?: string): TranslationProvider;
126
+ }
127
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "module": "ESNext",
7
+ "target": "ES2020"
8
+ },
9
+ "include": [
10
+ "src/**/*"
11
+ ],
12
+ "exclude": [
13
+ "node_modules",
14
+ "dist",
15
+ "**/*.test.ts"
16
+ ]
17
+ }