locai 2.1.0
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/README.md +928 -0
- package/dist/cli.js +718 -0
- package/dist/commands/review.js +357 -0
- package/dist/commands/translator.js +29 -0
- package/dist/config/index.js +52 -0
- package/dist/core/adapters/base-adapter.js +1 -0
- package/dist/core/adapters/factory.js +44 -0
- package/dist/core/adapters/json-adapter.js +20 -0
- package/dist/core/adapters/po-adapter.js +50 -0
- package/dist/core/adapters/properties-adapter.js +31 -0
- package/dist/core/adapters/yaml-adapter.js +24 -0
- package/dist/core/context-processor.js +247 -0
- package/dist/core/detection/framework-detector.js +56 -0
- package/dist/core/detection/structure-detector.js +30 -0
- package/dist/core/fallback-provider.js +442 -0
- package/dist/core/orchestrator.js +438 -0
- package/dist/core/pipeline/Pipeline.js +48 -0
- package/dist/core/pipeline/PipelineStep.js +13 -0
- package/dist/core/pipeline/context.js +21 -0
- package/dist/core/pipeline/steps/CacheReadStep.js +80 -0
- package/dist/core/pipeline/steps/CacheWriteStep.js +32 -0
- package/dist/core/pipeline/steps/ConfidenceCheckStep.js +67 -0
- package/dist/core/pipeline/steps/GlossaryPostStep.js +17 -0
- package/dist/core/pipeline/steps/GlossaryPreStep.js +20 -0
- package/dist/core/pipeline/steps/InputValidationStep.js +26 -0
- package/dist/core/pipeline/steps/QualityCheckStep.js +48 -0
- package/dist/core/pipeline/steps/ToneCheckStep.js +58 -0
- package/dist/core/pipeline/steps/TranslationStep.js +65 -0
- package/dist/core/pipeline/steps/VectorCacheReadStep.js +84 -0
- package/dist/core/pipeline/steps/VectorCacheWriteStep.js +42 -0
- package/dist/core/pipeline/steps/__tests__/ToneCheckStep.test.js +100 -0
- package/dist/core/provider-factory.js +139 -0
- package/dist/providers/base-provider.js +230 -0
- package/dist/providers/dashscope.js +128 -0
- package/dist/providers/deepseek.js +111 -0
- package/dist/providers/gemini.js +135 -0
- package/dist/providers/openai.js +203 -0
- package/dist/providers/xai.js +111 -0
- package/dist/services/embedding-provider.js +50 -0
- package/dist/services/translation-service.js +609 -0
- package/dist/services/vector-store.js +79 -0
- package/dist/utils/ai-context-analyzer.js +460 -0
- package/dist/utils/confidence-scorer.js +277 -0
- package/dist/utils/error-helper.js +386 -0
- package/dist/utils/file-manager.js +357 -0
- package/dist/utils/glossary-manager.js +194 -0
- package/dist/utils/graceful-shutdown.js +118 -0
- package/dist/utils/input-validator.js +663 -0
- package/dist/utils/logger.js +374 -0
- package/dist/utils/object-transformer.js +177 -0
- package/dist/utils/progress-tracker.js +277 -0
- package/dist/utils/prompt-templates.js +415 -0
- package/dist/utils/quality/__tests__/tone-verifier.test.js +91 -0
- package/dist/utils/quality/base-checker.js +73 -0
- package/dist/utils/quality/code-block-checker.js +214 -0
- package/dist/utils/quality/html-tag-checker.js +64 -0
- package/dist/utils/quality/index.js +220 -0
- package/dist/utils/quality/length-checker.js +140 -0
- package/dist/utils/quality/markdown-checker.js +220 -0
- package/dist/utils/quality/placeholder-checker.js +142 -0
- package/dist/utils/quality/punctuation-checker.js +50 -0
- package/dist/utils/quality/quote-balance-checker.js +143 -0
- package/dist/utils/quality/special-characters-checker.js +229 -0
- package/dist/utils/quality/style-guide-checker.js +192 -0
- package/dist/utils/quality/text-sanitizer.js +120 -0
- package/dist/utils/quality/tone-verifier.js +91 -0
- package/dist/utils/rate-limiter.js +398 -0
- package/dist/utils/retry-helper.js +166 -0
- package/dist/utils/state-manager.js +162 -0
- package/dist/utils/statistics-manager.js +51 -0
- package/dist/utils/ui-manager.js +98 -0
- package/package.json +80 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import { FileManager } from "./file-manager.js"; // Note: Changed named import to default import based on file-manager migration
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { dirname } from "path";
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = dirname(__filename);
|
|
9
|
+
/**
|
|
10
|
+
* StateManager - Tracks changes in source locale files using hash-based state management
|
|
11
|
+
* This enables detection of deleted/modified keys to synchronize target locale files
|
|
12
|
+
*/
|
|
13
|
+
class StateManager {
|
|
14
|
+
options;
|
|
15
|
+
constructor(options = {}) {
|
|
16
|
+
// Support both direct options and nested stateTracking config
|
|
17
|
+
const stateTracking = "stateTracking" in options ? options.stateTracking : options;
|
|
18
|
+
this.options = {
|
|
19
|
+
enabled: stateTracking.enabled !== false,
|
|
20
|
+
stateFileName: stateTracking.stateFileName || "localization.state.json",
|
|
21
|
+
stateDir: stateTracking.stateDir || ".localize-cache",
|
|
22
|
+
...stateTracking,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate a SHA-256 hash for given text content
|
|
27
|
+
*/
|
|
28
|
+
_generateHash(text) {
|
|
29
|
+
if (typeof text !== "string") {
|
|
30
|
+
text = JSON.stringify(text);
|
|
31
|
+
}
|
|
32
|
+
return crypto.createHash("sha256").update(text, "utf8").digest("hex");
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Get the full path to the state file
|
|
36
|
+
*/
|
|
37
|
+
_getStateFilePath(projectRoot) {
|
|
38
|
+
const stateDir = path.join(projectRoot, this.options.stateDir);
|
|
39
|
+
return path.join(stateDir, this.options.stateFileName);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Load previous state from state file
|
|
43
|
+
*/
|
|
44
|
+
async loadState(projectRoot) {
|
|
45
|
+
try {
|
|
46
|
+
const stateFilePath = this._getStateFilePath(projectRoot);
|
|
47
|
+
const state = await FileManager.readJSON(stateFilePath);
|
|
48
|
+
return state || {};
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
// State file doesn't exist or is corrupted - return empty state
|
|
52
|
+
if (error.message.includes("File read error") || error.code === "ENOENT") {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
throw new Error(`Failed to load state: ${error.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Save current state to state file
|
|
60
|
+
*/
|
|
61
|
+
async saveState(projectRoot, state) {
|
|
62
|
+
try {
|
|
63
|
+
const stateFilePath = this._getStateFilePath(projectRoot);
|
|
64
|
+
// Ensure state directory exists
|
|
65
|
+
const stateDir = path.dirname(stateFilePath);
|
|
66
|
+
await FileManager.ensureDir(stateDir);
|
|
67
|
+
// Save state with metadata
|
|
68
|
+
const stateWithMetadata = {
|
|
69
|
+
...state,
|
|
70
|
+
_metadata: {
|
|
71
|
+
lastUpdated: new Date().toISOString(),
|
|
72
|
+
version: "1.0.0",
|
|
73
|
+
toolVersion: JSON.parse(readFileSync(path.join(__dirname, "../../package.json"), "utf8")).version,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
await FileManager.writeJSON(stateFilePath, stateWithMetadata);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new Error(`Failed to save state: ${error.message}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Generate state object from source content by creating hashes for each key
|
|
85
|
+
*/
|
|
86
|
+
generateStateFromSource(sourceContent) {
|
|
87
|
+
const state = {};
|
|
88
|
+
for (const [key, value] of Object.entries(sourceContent)) {
|
|
89
|
+
// Generate hash for the value content
|
|
90
|
+
state[key] = this._generateHash(value);
|
|
91
|
+
}
|
|
92
|
+
return state;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Compare two states and identify changes
|
|
96
|
+
*/
|
|
97
|
+
compareStates(previousState, currentState) {
|
|
98
|
+
const deletedKeys = [];
|
|
99
|
+
const modifiedKeys = [];
|
|
100
|
+
const newKeys = [];
|
|
101
|
+
// Remove metadata from comparison if it exists
|
|
102
|
+
const cleanPreviousState = { ...previousState };
|
|
103
|
+
const cleanCurrentState = { ...currentState };
|
|
104
|
+
delete cleanPreviousState["_metadata"];
|
|
105
|
+
delete cleanCurrentState["_metadata"];
|
|
106
|
+
// Find deleted keys (exist in previous but not in current)
|
|
107
|
+
for (const key of Object.keys(cleanPreviousState)) {
|
|
108
|
+
if (!(key in cleanCurrentState)) {
|
|
109
|
+
deletedKeys.push(key);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Find modified and new keys
|
|
113
|
+
for (const [key, currentHash] of Object.entries(cleanCurrentState)) {
|
|
114
|
+
if (!(key in cleanPreviousState)) {
|
|
115
|
+
// New key
|
|
116
|
+
newKeys.push(key);
|
|
117
|
+
}
|
|
118
|
+
else if (cleanPreviousState[key] !== currentHash) {
|
|
119
|
+
// Modified key (hash changed)
|
|
120
|
+
modifiedKeys.push(key);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
deletedKeys,
|
|
125
|
+
modifiedKeys,
|
|
126
|
+
newKeys,
|
|
127
|
+
hasChanges: deletedKeys.length > 0 || modifiedKeys.length > 0 || newKeys.length > 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Get statistics about the state comparison
|
|
132
|
+
*/
|
|
133
|
+
getComparisonStats(comparison) {
|
|
134
|
+
return {
|
|
135
|
+
totalChanges: comparison.deletedKeys.length +
|
|
136
|
+
comparison.modifiedKeys.length +
|
|
137
|
+
comparison.newKeys.length,
|
|
138
|
+
deletedCount: comparison.deletedKeys.length,
|
|
139
|
+
modifiedCount: comparison.modifiedKeys.length,
|
|
140
|
+
newCount: comparison.newKeys.length,
|
|
141
|
+
hasChanges: comparison.hasChanges,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Clean up old state files (maintenance utility)
|
|
146
|
+
*/
|
|
147
|
+
async cleanupState(projectRoot) {
|
|
148
|
+
try {
|
|
149
|
+
const stateFilePath = this._getStateFilePath(projectRoot);
|
|
150
|
+
await FileManager.deleteFile(stateFilePath);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// File doesn't exist - that's fine
|
|
155
|
+
if (error.message.includes("ENOENT")) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
throw new Error(`Failed to cleanup state: ${error.message}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export default StateManager;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statistics manager to track translation progress and metrics
|
|
3
|
+
*/
|
|
4
|
+
export class StatisticsManager {
|
|
5
|
+
stats;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.stats = this.initializeGlobalStats();
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Initialize global statistics structure
|
|
11
|
+
*/
|
|
12
|
+
initializeGlobalStats() {
|
|
13
|
+
return {
|
|
14
|
+
total: 0,
|
|
15
|
+
byCategory: {},
|
|
16
|
+
details: {},
|
|
17
|
+
totalTime: 0,
|
|
18
|
+
success: 0,
|
|
19
|
+
failed: 0,
|
|
20
|
+
skipped: 0,
|
|
21
|
+
languages: {},
|
|
22
|
+
startTime: new Date().toISOString(),
|
|
23
|
+
orchestrators: [],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Reset statistics
|
|
28
|
+
*/
|
|
29
|
+
reset() {
|
|
30
|
+
this.stats = this.initializeGlobalStats();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get current stats
|
|
34
|
+
*/
|
|
35
|
+
getStats() {
|
|
36
|
+
return this.stats;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Initialize language stats
|
|
40
|
+
*/
|
|
41
|
+
initLanguageStats(langCode) {
|
|
42
|
+
this.stats.languages[langCode] = {
|
|
43
|
+
processed: 0,
|
|
44
|
+
added: 0,
|
|
45
|
+
skipped: 0,
|
|
46
|
+
failed: 0,
|
|
47
|
+
timeMs: 0,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export default new StatisticsManager();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI Manager for handling CLI output and user interaction
|
|
3
|
+
*/
|
|
4
|
+
export class UIManager {
|
|
5
|
+
consoleLock;
|
|
6
|
+
constructor() {
|
|
7
|
+
this.consoleLock = {
|
|
8
|
+
queue: [],
|
|
9
|
+
isLocked: false,
|
|
10
|
+
async log(message) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const executeLog = () => {
|
|
13
|
+
console.log(message);
|
|
14
|
+
this.isLocked = false;
|
|
15
|
+
resolve();
|
|
16
|
+
this._processQueue();
|
|
17
|
+
};
|
|
18
|
+
if (this.isLocked) {
|
|
19
|
+
this.queue.push(executeLog);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
this.isLocked = true;
|
|
23
|
+
executeLog();
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
_processQueue() {
|
|
28
|
+
if (this.queue.length > 0 && !this.isLocked) {
|
|
29
|
+
this.isLocked = true;
|
|
30
|
+
const nextLog = this.queue.shift();
|
|
31
|
+
if (nextLog)
|
|
32
|
+
nextLog();
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Log a message safely preventing overlap
|
|
39
|
+
*/
|
|
40
|
+
async log(message) {
|
|
41
|
+
await this.consoleLock.log(message);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Display global summary of translation
|
|
45
|
+
*/
|
|
46
|
+
async displayGlobalSummary(stats, totalLanguages) {
|
|
47
|
+
await this.log("\n" + "=".repeat(60));
|
|
48
|
+
await this.log("Global Translation Summary");
|
|
49
|
+
await this.log("=".repeat(60));
|
|
50
|
+
await this.log(`\nLanguages Processed: ${totalLanguages}`);
|
|
51
|
+
await this.log(`Total Translations: ${stats.total}`);
|
|
52
|
+
await this.log(`Success: ${stats.success}`);
|
|
53
|
+
await this.log(`Failed: ${stats.failed}`);
|
|
54
|
+
await this.log(`Skipped: ${stats.skipped}`);
|
|
55
|
+
await this.log(`Total Time: ${stats.totalTime?.toFixed(1) || 0}s`);
|
|
56
|
+
await this.log(`Average per language: ${(stats.totalTime / (totalLanguages || 1)).toFixed(1)}s`);
|
|
57
|
+
// Display detailed language stats
|
|
58
|
+
if (Object.keys(stats.languages).length > 0) {
|
|
59
|
+
await this.log("\n" + "-".repeat(60));
|
|
60
|
+
await this.log("Per-language Performance:");
|
|
61
|
+
await this.log("-".repeat(60));
|
|
62
|
+
for (const [lang, langStats] of Object.entries(stats.languages)) {
|
|
63
|
+
const timeSeconds = langStats.timeMs / 1000;
|
|
64
|
+
await this.log(` ${lang.padEnd(4)} | ${String(langStats.added).padStart(3)} added | ${String(langStats.skipped).padStart(3)} skipped | ${String(langStats.failed).padStart(2)} failed | ${timeSeconds.toFixed(1)}s`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Only show categories if we have them
|
|
68
|
+
if (Object.keys(stats.byCategory).length > 0) {
|
|
69
|
+
await this.log("\n" + "-".repeat(60));
|
|
70
|
+
await this.log("Context Analysis by Category:");
|
|
71
|
+
await this.log("-".repeat(60));
|
|
72
|
+
for (const [category, count] of Object.entries(stats.byCategory)) {
|
|
73
|
+
const details = stats.details[category];
|
|
74
|
+
if (details && details.samples > 0) {
|
|
75
|
+
const avgConfidence = details.totalConfidence / details.samples;
|
|
76
|
+
const confidenceStr = `${(avgConfidence * 100).toFixed(1)}%`;
|
|
77
|
+
await this.log(` ${category}: ${count} items (${confidenceStr} avg confidence)`);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await this.log(` ${category}: ${count} items`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Clear completion message - only once
|
|
85
|
+
await this.log("\n" + "=".repeat(60));
|
|
86
|
+
const duration = stats.totalDuration || 0;
|
|
87
|
+
await this.log(`All operations completed successfully in ${duration.toFixed(1)}s`);
|
|
88
|
+
await this.log("=".repeat(60) + "\n");
|
|
89
|
+
}
|
|
90
|
+
async logBatchResults(batchResults) {
|
|
91
|
+
for (const result of batchResults) {
|
|
92
|
+
if (result && result.savedMessage) {
|
|
93
|
+
await this.log(result.savedMessage);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export default new UIManager();
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "locai",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/core/orchestrator.js",
|
|
6
|
+
"directories": {
|
|
7
|
+
"src": "src",
|
|
8
|
+
"dist": "dist"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"locai": "./dist/cli.js",
|
|
12
|
+
"localize": "./dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"start": "node dist/core/orchestrator.js",
|
|
16
|
+
"dev": "tsx src/core/orchestrator.js",
|
|
17
|
+
"build": "tsc",
|
|
18
|
+
"type-check": "tsc --noEmit",
|
|
19
|
+
"lint": "eslint .",
|
|
20
|
+
"lint:fix": "eslint . --fix",
|
|
21
|
+
"format": "prettier --write \"**/*.{js,json,md,jsx,ts,tsx,css}\"",
|
|
22
|
+
"format:check": "prettier --check \"**/*.{js,json,md,jsx,ts,tsx,css}\"",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"test:watch": "vitest",
|
|
25
|
+
"test:coverage": "vitest run --coverage",
|
|
26
|
+
"prepublishOnly": "pnpm build"
|
|
27
|
+
},
|
|
28
|
+
"keywords": [
|
|
29
|
+
"translation",
|
|
30
|
+
"localization",
|
|
31
|
+
"i18n",
|
|
32
|
+
"nextjs",
|
|
33
|
+
"ai-translation"
|
|
34
|
+
],
|
|
35
|
+
"author": {
|
|
36
|
+
"name": "Ahmet Enes Dur",
|
|
37
|
+
"email": "ahmetenesdur@gmail.com",
|
|
38
|
+
"url": "https://github.com/ahmetenesdur"
|
|
39
|
+
},
|
|
40
|
+
"license": "ISC",
|
|
41
|
+
"description": "CLI tool for translating Next.js locale files with advanced settings and multiple AI providers",
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=14.13.0"
|
|
44
|
+
},
|
|
45
|
+
"exports": {
|
|
46
|
+
".": "./dist/core/orchestrator.js",
|
|
47
|
+
"./orchestrator": "./dist/core/orchestrator.js",
|
|
48
|
+
"./translator": "./dist/commands/translator.js"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"axios": "1.13.1",
|
|
52
|
+
"c12": "^3.3.2",
|
|
53
|
+
"commander": "^14.0.2",
|
|
54
|
+
"dot-properties": "^1.1.0",
|
|
55
|
+
"dotenv": "^17.2.3",
|
|
56
|
+
"gettext-parser": "^8.0.0",
|
|
57
|
+
"jiti": "^2.6.1",
|
|
58
|
+
"js-yaml": "^4.1.1",
|
|
59
|
+
"lru-cache": "^11.2.2",
|
|
60
|
+
"p-limit": "^7.2.0",
|
|
61
|
+
"vectra": "^0.11.1"
|
|
62
|
+
},
|
|
63
|
+
"devDependencies": {
|
|
64
|
+
"@eslint/js": "^9.39.1",
|
|
65
|
+
"@types/js-yaml": "^4.0.9",
|
|
66
|
+
"@types/node": "^25.0.1",
|
|
67
|
+
"@vitest/coverage-v8": "^4.0.15",
|
|
68
|
+
"eslint": "^9.39.1",
|
|
69
|
+
"eslint-config-prettier": "^10.1.8",
|
|
70
|
+
"globals": "^16.5.0",
|
|
71
|
+
"prettier": "^3.6.2",
|
|
72
|
+
"std-mocks": "^2.0.0",
|
|
73
|
+
"tsx": "^4.21.0",
|
|
74
|
+
"typescript": "^5.9.3",
|
|
75
|
+
"vitest": "^4.0.15"
|
|
76
|
+
},
|
|
77
|
+
"files": [
|
|
78
|
+
"dist/"
|
|
79
|
+
]
|
|
80
|
+
}
|