@mxml3gend/gloss 0.1.1 → 0.1.3
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 +74 -0
- package/dist/baseline.js +118 -0
- package/dist/cache.js +78 -0
- package/dist/cacheMetrics.js +120 -0
- package/dist/check.js +510 -0
- package/dist/config.js +214 -10
- package/dist/fs.js +105 -6
- package/dist/gitDiff.js +113 -0
- package/dist/hooks.js +101 -0
- package/dist/index.js +437 -12
- package/dist/renameKeyUsage.js +4 -12
- package/dist/server.js +163 -9
- package/dist/translationKeys.js +20 -0
- package/dist/translationTree.js +42 -0
- package/dist/typegen.js +30 -0
- package/dist/ui/Gloss_logo.png +0 -0
- package/dist/ui/assets/index-BCr07xD_.js +21 -0
- package/dist/ui/assets/index-CjmLcA1x.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/logo_full.png +0 -0
- package/dist/usage.js +105 -22
- package/dist/usageExtractor.js +151 -0
- package/dist/usageScanner.js +110 -28
- package/dist/xliff.js +92 -0
- package/package.json +15 -5
- package/dist/ui/assets/index-CREq9Gop.css +0 -1
- package/dist/ui/assets/index-Dhb2pVPI.js +0 -10
package/dist/config.js
CHANGED
|
@@ -27,10 +27,70 @@ const normalizeScanConfig = (value) => {
|
|
|
27
27
|
const scan = value;
|
|
28
28
|
const include = normalizeScanPatterns(scan.include);
|
|
29
29
|
const exclude = normalizeScanPatterns(scan.exclude);
|
|
30
|
-
|
|
30
|
+
const mode = scan.mode === "regex" || scan.mode === "ast" ? scan.mode : undefined;
|
|
31
|
+
if (!include && !exclude && !mode) {
|
|
31
32
|
return undefined;
|
|
32
33
|
}
|
|
33
|
-
return { include, exclude };
|
|
34
|
+
return { include, exclude, mode };
|
|
35
|
+
};
|
|
36
|
+
const DEFAULT_HARDCODED_MIN_LENGTH = 3;
|
|
37
|
+
const DEFAULT_STRICT_PLACEHOLDERS = true;
|
|
38
|
+
const normalizeStrictPlaceholders = (value) => {
|
|
39
|
+
if (value === undefined) {
|
|
40
|
+
return DEFAULT_STRICT_PLACEHOLDERS;
|
|
41
|
+
}
|
|
42
|
+
if (typeof value !== "boolean") {
|
|
43
|
+
throw new GlossConfigError("INVALID_CONFIG", "`strictPlaceholders` must be a boolean when provided.");
|
|
44
|
+
}
|
|
45
|
+
return value;
|
|
46
|
+
};
|
|
47
|
+
const normalizeHardcodedTextConfig = (value) => {
|
|
48
|
+
if (value === undefined) {
|
|
49
|
+
return {
|
|
50
|
+
enabled: true,
|
|
51
|
+
minLength: DEFAULT_HARDCODED_MIN_LENGTH,
|
|
52
|
+
excludePatterns: [],
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
56
|
+
throw new GlossConfigError("INVALID_CONFIG", "`hardcodedText` must be an object when provided.");
|
|
57
|
+
}
|
|
58
|
+
const hardcodedText = value;
|
|
59
|
+
const enabled = typeof hardcodedText.enabled === "boolean" ? hardcodedText.enabled : true;
|
|
60
|
+
const minLength = typeof hardcodedText.minLength === "number" &&
|
|
61
|
+
Number.isFinite(hardcodedText.minLength) &&
|
|
62
|
+
hardcodedText.minLength >= 1
|
|
63
|
+
? Math.floor(hardcodedText.minLength)
|
|
64
|
+
: DEFAULT_HARDCODED_MIN_LENGTH;
|
|
65
|
+
if (hardcodedText.minLength !== undefined &&
|
|
66
|
+
(typeof hardcodedText.minLength !== "number" ||
|
|
67
|
+
!Number.isFinite(hardcodedText.minLength) ||
|
|
68
|
+
hardcodedText.minLength < 1)) {
|
|
69
|
+
throw new GlossConfigError("INVALID_CONFIG", "`hardcodedText.minLength` must be a number >= 1 when provided.");
|
|
70
|
+
}
|
|
71
|
+
if (hardcodedText.excludePatterns !== undefined &&
|
|
72
|
+
!Array.isArray(hardcodedText.excludePatterns)) {
|
|
73
|
+
throw new GlossConfigError("INVALID_CONFIG", "`hardcodedText.excludePatterns` must be a string array when provided.");
|
|
74
|
+
}
|
|
75
|
+
const excludePatterns = Array.isArray(hardcodedText.excludePatterns)
|
|
76
|
+
? hardcodedText.excludePatterns
|
|
77
|
+
.filter((entry) => typeof entry === "string")
|
|
78
|
+
.map((entry) => entry.trim())
|
|
79
|
+
.filter((entry) => entry.length > 0)
|
|
80
|
+
: [];
|
|
81
|
+
for (const pattern of excludePatterns) {
|
|
82
|
+
try {
|
|
83
|
+
void new RegExp(pattern);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new GlossConfigError("INVALID_CONFIG", `Invalid regex in hardcodedText.excludePatterns: ${pattern}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
enabled,
|
|
91
|
+
minLength,
|
|
92
|
+
excludePatterns,
|
|
93
|
+
};
|
|
34
94
|
};
|
|
35
95
|
const CONFIG_FILE_NAMES = [
|
|
36
96
|
"gloss.config.ts",
|
|
@@ -39,6 +99,37 @@ const CONFIG_FILE_NAMES = [
|
|
|
39
99
|
"gloss.config.mjs",
|
|
40
100
|
"gloss.config.cjs",
|
|
41
101
|
];
|
|
102
|
+
const LOCALE_CODE_PATTERN = /^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})*$/;
|
|
103
|
+
const AUTO_DISCOVERY_IGNORED_DIRECTORIES = new Set([
|
|
104
|
+
"node_modules",
|
|
105
|
+
".git",
|
|
106
|
+
".next",
|
|
107
|
+
".nuxt",
|
|
108
|
+
".turbo",
|
|
109
|
+
"dist",
|
|
110
|
+
"build",
|
|
111
|
+
"coverage",
|
|
112
|
+
"out",
|
|
113
|
+
]);
|
|
114
|
+
const DIRECTORY_NAME_SCORES = new Map([
|
|
115
|
+
["i18n", 80],
|
|
116
|
+
["locales", 80],
|
|
117
|
+
["locale", 60],
|
|
118
|
+
["translations", 55],
|
|
119
|
+
["translation", 45],
|
|
120
|
+
["lang", 35],
|
|
121
|
+
["langs", 35],
|
|
122
|
+
["messages", 25],
|
|
123
|
+
]);
|
|
124
|
+
const normalizePath = (filePath) => filePath.split(path.sep).join("/").replace(/^\.\//, "");
|
|
125
|
+
const isLikelyLocaleCode = (value) => LOCALE_CODE_PATTERN.test(value);
|
|
126
|
+
const scoreDirectoryName = (directoryPath) => {
|
|
127
|
+
const segments = normalizePath(directoryPath).split("/");
|
|
128
|
+
return segments.reduce((score, segment) => {
|
|
129
|
+
const nextScore = DIRECTORY_NAME_SCORES.get(segment.toLowerCase());
|
|
130
|
+
return score + (nextScore ?? 0);
|
|
131
|
+
}, 0);
|
|
132
|
+
};
|
|
42
133
|
const resolveLocalesDirectory = (cwd, localesPath) => {
|
|
43
134
|
if (path.isAbsolute(localesPath)) {
|
|
44
135
|
return localesPath;
|
|
@@ -49,9 +140,13 @@ const discoverLocales = async (cwd, localesPath) => {
|
|
|
49
140
|
const directory = resolveLocalesDirectory(cwd, localesPath);
|
|
50
141
|
try {
|
|
51
142
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
52
|
-
|
|
143
|
+
const localeCandidates = entries
|
|
53
144
|
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
54
145
|
.map((entry) => path.basename(entry.name, ".json").trim())
|
|
146
|
+
.filter((entry) => entry.length > 0);
|
|
147
|
+
const likelyLocales = localeCandidates.filter(isLikelyLocaleCode);
|
|
148
|
+
const localesToUse = likelyLocales.length > 0 ? likelyLocales : localeCandidates;
|
|
149
|
+
return localesToUse
|
|
55
150
|
.filter((entry) => entry.length > 0)
|
|
56
151
|
.sort((a, b) => a.localeCompare(b));
|
|
57
152
|
}
|
|
@@ -59,6 +154,83 @@ const discoverLocales = async (cwd, localesPath) => {
|
|
|
59
154
|
return [];
|
|
60
155
|
}
|
|
61
156
|
};
|
|
157
|
+
const discoverLocaleDirectoryCandidatesInternal = async (cwd) => {
|
|
158
|
+
const candidates = [];
|
|
159
|
+
const visit = async (directory) => {
|
|
160
|
+
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
161
|
+
const directoryLocales = entries
|
|
162
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
|
163
|
+
.map((entry) => path.basename(entry.name, ".json").trim())
|
|
164
|
+
.filter((entry) => isLikelyLocaleCode(entry));
|
|
165
|
+
if (directoryLocales.length > 0) {
|
|
166
|
+
const normalizedDirectory = normalizePath(path.relative(cwd, directory));
|
|
167
|
+
const uniqueLocales = Array.from(new Set(directoryLocales)).sort((a, b) => a.localeCompare(b));
|
|
168
|
+
const depth = normalizedDirectory.length === 0
|
|
169
|
+
? 0
|
|
170
|
+
: normalizedDirectory.split("/").length;
|
|
171
|
+
candidates.push({
|
|
172
|
+
directoryPath: directory,
|
|
173
|
+
locales: uniqueLocales,
|
|
174
|
+
depth,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (!entry.isDirectory()) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (entry.name.startsWith(".") ||
|
|
182
|
+
AUTO_DISCOVERY_IGNORED_DIRECTORIES.has(entry.name)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
await visit(path.join(directory, entry.name));
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
await visit(cwd);
|
|
189
|
+
return candidates;
|
|
190
|
+
};
|
|
191
|
+
const scoreLocaleDirectoryCandidates = (candidates, preferredLocales) => {
|
|
192
|
+
const scored = candidates.map((candidate) => {
|
|
193
|
+
const localeMatches = preferredLocales.filter((locale) => candidate.locales.includes(locale));
|
|
194
|
+
const allPreferredMatch = preferredLocales.length > 0 &&
|
|
195
|
+
preferredLocales.every((locale) => candidate.locales.includes(locale));
|
|
196
|
+
const directoryNameScore = scoreDirectoryName(candidate.directoryPath);
|
|
197
|
+
const srcHintScore = normalizePath(candidate.directoryPath).includes("/src/")
|
|
198
|
+
? 15
|
|
199
|
+
: 0;
|
|
200
|
+
const depthScore = Math.max(0, 30 - candidate.depth * 3);
|
|
201
|
+
const localeCountScore = candidate.locales.length * 10;
|
|
202
|
+
const preferredScore = localeMatches.length * 25 + (allPreferredMatch ? 80 : 0);
|
|
203
|
+
const score = directoryNameScore +
|
|
204
|
+
srcHintScore +
|
|
205
|
+
depthScore +
|
|
206
|
+
localeCountScore +
|
|
207
|
+
preferredScore;
|
|
208
|
+
return { candidate, score };
|
|
209
|
+
});
|
|
210
|
+
scored.sort((left, right) => {
|
|
211
|
+
if (left.score !== right.score) {
|
|
212
|
+
return right.score - left.score;
|
|
213
|
+
}
|
|
214
|
+
if (left.candidate.depth !== right.candidate.depth) {
|
|
215
|
+
return left.candidate.depth - right.candidate.depth;
|
|
216
|
+
}
|
|
217
|
+
return left.candidate.directoryPath.localeCompare(right.candidate.directoryPath);
|
|
218
|
+
});
|
|
219
|
+
return scored;
|
|
220
|
+
};
|
|
221
|
+
const selectLocaleDirectoryCandidate = (candidates, preferredLocales) => {
|
|
222
|
+
return scoreLocaleDirectoryCandidates(candidates, preferredLocales)[0]?.candidate;
|
|
223
|
+
};
|
|
224
|
+
const resolveDiscoveredPath = (cwd, directoryPath) => {
|
|
225
|
+
const relative = path.relative(cwd, directoryPath);
|
|
226
|
+
if (!relative || relative === ".") {
|
|
227
|
+
return ".";
|
|
228
|
+
}
|
|
229
|
+
if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
|
|
230
|
+
return normalizePath(relative);
|
|
231
|
+
}
|
|
232
|
+
return directoryPath;
|
|
233
|
+
};
|
|
62
234
|
const resolveConfigPath = async (cwd) => {
|
|
63
235
|
for (const fileName of CONFIG_FILE_NAMES) {
|
|
64
236
|
const candidatePath = path.join(cwd, fileName);
|
|
@@ -78,6 +250,17 @@ const normalizeLoadedConfig = (value) => {
|
|
|
78
250
|
}
|
|
79
251
|
return value;
|
|
80
252
|
};
|
|
253
|
+
export async function discoverLocaleDirectoryCandidates(cwdInput) {
|
|
254
|
+
const cwd = cwdInput ?? process.env.INIT_CWD ?? process.cwd();
|
|
255
|
+
const candidates = await discoverLocaleDirectoryCandidatesInternal(cwd);
|
|
256
|
+
const scored = scoreLocaleDirectoryCandidates(candidates, []);
|
|
257
|
+
return scored.map(({ candidate, score }) => ({
|
|
258
|
+
path: resolveDiscoveredPath(cwd, candidate.directoryPath),
|
|
259
|
+
locales: candidate.locales,
|
|
260
|
+
depth: candidate.depth,
|
|
261
|
+
score,
|
|
262
|
+
}));
|
|
263
|
+
}
|
|
81
264
|
export async function loadGlossConfig() {
|
|
82
265
|
const cwd = process.env.INIT_CWD || process.cwd();
|
|
83
266
|
const configPath = await resolveConfigPath(cwd);
|
|
@@ -93,10 +276,10 @@ export async function loadGlossConfig() {
|
|
|
93
276
|
if (!cfg || typeof cfg !== "object") {
|
|
94
277
|
throw new GlossConfigError("INVALID_CONFIG", "Default export must be a config object.");
|
|
95
278
|
}
|
|
96
|
-
if (
|
|
97
|
-
|
|
279
|
+
if (cfg.path !== undefined &&
|
|
280
|
+
(typeof cfg.path !== "string" || !cfg.path.trim())) {
|
|
281
|
+
throw new GlossConfigError("INVALID_CONFIG", "`path` must be a non-empty string when provided.");
|
|
98
282
|
}
|
|
99
|
-
const translationsPath = cfg.path.trim();
|
|
100
283
|
if (cfg.locales !== undefined && !Array.isArray(cfg.locales)) {
|
|
101
284
|
throw new GlossConfigError("INVALID_CONFIG", "`locales` must be an array of locale codes when provided.");
|
|
102
285
|
}
|
|
@@ -105,16 +288,35 @@ export async function loadGlossConfig() {
|
|
|
105
288
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
|
106
289
|
.filter((entry) => entry.length > 0)
|
|
107
290
|
: [];
|
|
291
|
+
const configuredPath = typeof cfg.path === "string" ? cfg.path.trim() : "";
|
|
292
|
+
const discoveredDirectoryCandidate = configuredPath
|
|
293
|
+
? null
|
|
294
|
+
: selectLocaleDirectoryCandidate(await discoverLocaleDirectoryCandidatesInternal(cwd), configuredLocales);
|
|
295
|
+
const translationsPath = configuredPath
|
|
296
|
+
? configuredPath
|
|
297
|
+
: discoveredDirectoryCandidate
|
|
298
|
+
? resolveDiscoveredPath(cwd, discoveredDirectoryCandidate.directoryPath)
|
|
299
|
+
: "";
|
|
300
|
+
if (!translationsPath) {
|
|
301
|
+
throw new GlossConfigError("NO_LOCALES", "No locale directory found. Set `path` in config or add locale JSON files (for example `src/locales/en.json`).");
|
|
302
|
+
}
|
|
108
303
|
const locales = configuredLocales.length > 0
|
|
109
304
|
? configuredLocales
|
|
110
|
-
:
|
|
305
|
+
: discoveredDirectoryCandidate
|
|
306
|
+
? discoveredDirectoryCandidate.locales
|
|
307
|
+
: await discoverLocales(cwd, translationsPath);
|
|
111
308
|
if (locales.length === 0) {
|
|
112
309
|
throw new GlossConfigError("NO_LOCALES", `No locales found. Add "locales" in config or place *.json files in ${translationsPath}.`);
|
|
113
310
|
}
|
|
114
|
-
if (
|
|
115
|
-
|
|
311
|
+
if (cfg.defaultLocale !== undefined &&
|
|
312
|
+
(typeof cfg.defaultLocale !== "string" || !cfg.defaultLocale.trim())) {
|
|
313
|
+
throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be a non-empty string when provided.");
|
|
116
314
|
}
|
|
117
|
-
const defaultLocale = cfg.defaultLocale.trim()
|
|
315
|
+
const defaultLocale = typeof cfg.defaultLocale === "string" && cfg.defaultLocale.trim()
|
|
316
|
+
? cfg.defaultLocale.trim()
|
|
317
|
+
: locales.includes("en")
|
|
318
|
+
? "en"
|
|
319
|
+
: locales[0];
|
|
118
320
|
if (!locales.includes(defaultLocale)) {
|
|
119
321
|
throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be included in `locales`.");
|
|
120
322
|
}
|
|
@@ -124,7 +326,9 @@ export async function loadGlossConfig() {
|
|
|
124
326
|
defaultLocale,
|
|
125
327
|
path: translationsPath,
|
|
126
328
|
format: "json",
|
|
329
|
+
strictPlaceholders: normalizeStrictPlaceholders(cfg.strictPlaceholders),
|
|
127
330
|
scan: normalizeScanConfig(cfg.scan),
|
|
331
|
+
hardcodedText: normalizeHardcodedTextConfig(cfg.hardcodedText),
|
|
128
332
|
};
|
|
129
333
|
}
|
|
130
334
|
catch (e) {
|
package/dist/fs.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
const DEFAULT_WRITE_LOCK_TIMEOUT_MS = 4_000;
|
|
4
|
+
const DEFAULT_WRITE_LOCK_RETRY_MS = 50;
|
|
5
|
+
const WRITE_LOCK_FILE_NAME = ".gloss-write.lock";
|
|
6
|
+
export class WriteLockError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = "WriteLockError";
|
|
10
|
+
}
|
|
11
|
+
}
|
|
3
12
|
function projectRoot() {
|
|
4
13
|
return process.env.INIT_CWD || process.cwd();
|
|
5
14
|
}
|
|
@@ -12,6 +21,77 @@ function translationsDir(cfg) {
|
|
|
12
21
|
function localeFile(cfg, locale) {
|
|
13
22
|
return path.join(translationsDir(cfg), `${locale}.json`);
|
|
14
23
|
}
|
|
24
|
+
const compareKeys = (left, right) => {
|
|
25
|
+
if (left < right) {
|
|
26
|
+
return -1;
|
|
27
|
+
}
|
|
28
|
+
if (left > right) {
|
|
29
|
+
return 1;
|
|
30
|
+
}
|
|
31
|
+
return 0;
|
|
32
|
+
};
|
|
33
|
+
const isRecord = (value) => {
|
|
34
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
};
|
|
36
|
+
const sortTranslationTree = (value) => {
|
|
37
|
+
if (Array.isArray(value)) {
|
|
38
|
+
return value.map((entry) => sortTranslationTree(entry));
|
|
39
|
+
}
|
|
40
|
+
if (!isRecord(value)) {
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
const sorted = {};
|
|
44
|
+
const entries = Object.entries(value).sort(([leftKey], [rightKey]) => compareKeys(leftKey, rightKey));
|
|
45
|
+
for (const [key, entryValue] of entries) {
|
|
46
|
+
sorted[key] = sortTranslationTree(entryValue);
|
|
47
|
+
}
|
|
48
|
+
return sorted;
|
|
49
|
+
};
|
|
50
|
+
const sleep = (durationMs) => new Promise((resolve) => {
|
|
51
|
+
setTimeout(resolve, durationMs);
|
|
52
|
+
});
|
|
53
|
+
const parsePositiveInteger = (value, fallback) => {
|
|
54
|
+
if (!value) {
|
|
55
|
+
return fallback;
|
|
56
|
+
}
|
|
57
|
+
const parsed = Number.parseInt(value, 10);
|
|
58
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
return parsed;
|
|
62
|
+
};
|
|
63
|
+
const writeLockTimeoutMs = () => parsePositiveInteger(process.env.GLOSS_WRITE_LOCK_TIMEOUT_MS, DEFAULT_WRITE_LOCK_TIMEOUT_MS);
|
|
64
|
+
const writeLockRetryMs = () => parsePositiveInteger(process.env.GLOSS_WRITE_LOCK_RETRY_MS, DEFAULT_WRITE_LOCK_RETRY_MS);
|
|
65
|
+
const withTranslationsWriteLock = async (dir, run) => {
|
|
66
|
+
await fs.mkdir(dir, { recursive: true });
|
|
67
|
+
const lockFilePath = path.join(dir, WRITE_LOCK_FILE_NAME);
|
|
68
|
+
const timeoutMs = writeLockTimeoutMs();
|
|
69
|
+
const retryMs = writeLockRetryMs();
|
|
70
|
+
const startTime = Date.now();
|
|
71
|
+
let lockHandle = null;
|
|
72
|
+
while (!lockHandle) {
|
|
73
|
+
try {
|
|
74
|
+
lockHandle = await fs.open(lockFilePath, "wx");
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const errno = error;
|
|
78
|
+
if (errno.code !== "EEXIST") {
|
|
79
|
+
throw error;
|
|
80
|
+
}
|
|
81
|
+
if (Date.now() - startTime >= timeoutMs) {
|
|
82
|
+
throw new WriteLockError("Could not save translations because another Gloss save is in progress. Try again.");
|
|
83
|
+
}
|
|
84
|
+
await sleep(retryMs);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return await run();
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
await lockHandle.close().catch(() => undefined);
|
|
92
|
+
await fs.unlink(lockFilePath).catch(() => undefined);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
15
95
|
export async function readAllTranslations(cfg) {
|
|
16
96
|
const out = {};
|
|
17
97
|
for (const locale of cfg.locales) {
|
|
@@ -28,10 +108,29 @@ export async function readAllTranslations(cfg) {
|
|
|
28
108
|
}
|
|
29
109
|
export async function writeAllTranslations(cfg, data) {
|
|
30
110
|
const dir = translationsDir(cfg);
|
|
31
|
-
await
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
111
|
+
await withTranslationsWriteLock(dir, async () => {
|
|
112
|
+
const serialized = cfg.locales.map((locale) => {
|
|
113
|
+
return {
|
|
114
|
+
locale,
|
|
115
|
+
filePath: localeFile(cfg, locale),
|
|
116
|
+
json: JSON.stringify(sortTranslationTree(data[locale] ?? {}), null, 2) + "\n",
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
const operationId = `${Date.now()}-${process.pid}`;
|
|
120
|
+
const tempFiles = [];
|
|
121
|
+
try {
|
|
122
|
+
for (const entry of serialized) {
|
|
123
|
+
const tempPath = `${entry.filePath}.tmp-${operationId}-${entry.locale}`;
|
|
124
|
+
await fs.writeFile(tempPath, entry.json, "utf8");
|
|
125
|
+
tempFiles.push(tempPath);
|
|
126
|
+
}
|
|
127
|
+
for (let index = 0; index < serialized.length; index += 1) {
|
|
128
|
+
await fs.rename(tempFiles[index], serialized[index].filePath);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
await Promise.allSettled(tempFiles.map((filePath) => fs.unlink(filePath)));
|
|
133
|
+
throw error;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
37
136
|
}
|
package/dist/gitDiff.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { readAllTranslations } from "./fs.js";
|
|
5
|
+
import { flattenObject } from "./translationTree.js";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const projectRoot = () => process.env.INIT_CWD || process.cwd();
|
|
8
|
+
const normalizePath = (value) => value.split(path.sep).join("/");
|
|
9
|
+
const runGit = async (cwd, args) => {
|
|
10
|
+
const { stdout } = await execFileAsync("git", args, { cwd });
|
|
11
|
+
return stdout.trim();
|
|
12
|
+
};
|
|
13
|
+
const resolveLocaleFileRelativePath = (cfg, locale, cwd) => {
|
|
14
|
+
const directory = path.isAbsolute(cfg.path) ? cfg.path : path.resolve(cwd, cfg.path);
|
|
15
|
+
const filePath = path.resolve(directory, `${locale}.json`);
|
|
16
|
+
return normalizePath(path.relative(cwd, filePath));
|
|
17
|
+
};
|
|
18
|
+
const readJsonFromGit = async (cwd, ref, fileRelativePath) => {
|
|
19
|
+
if (!fileRelativePath ||
|
|
20
|
+
fileRelativePath.startsWith("../") ||
|
|
21
|
+
path.isAbsolute(fileRelativePath)) {
|
|
22
|
+
return {};
|
|
23
|
+
}
|
|
24
|
+
try {
|
|
25
|
+
const raw = await runGit(cwd, ["show", `${ref}:${fileRelativePath}`]);
|
|
26
|
+
return JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return {};
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
export async function buildGitKeyDiff(cfg, baseRef = "origin/main") {
|
|
33
|
+
const cwd = projectRoot();
|
|
34
|
+
try {
|
|
35
|
+
const insideRepo = await runGit(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
36
|
+
if (insideRepo !== "true") {
|
|
37
|
+
return {
|
|
38
|
+
available: false,
|
|
39
|
+
baseRef,
|
|
40
|
+
generatedAt: new Date().toISOString(),
|
|
41
|
+
keys: [],
|
|
42
|
+
error: "Current directory is not a git repository.",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return {
|
|
48
|
+
available: false,
|
|
49
|
+
baseRef,
|
|
50
|
+
generatedAt: new Date().toISOString(),
|
|
51
|
+
keys: [],
|
|
52
|
+
error: "Git is not available in this project.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
let resolvedBaseRef = "";
|
|
56
|
+
try {
|
|
57
|
+
resolvedBaseRef = await runGit(cwd, ["rev-parse", "--verify", `${baseRef}^{commit}`]);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {
|
|
61
|
+
available: false,
|
|
62
|
+
baseRef,
|
|
63
|
+
generatedAt: new Date().toISOString(),
|
|
64
|
+
keys: [],
|
|
65
|
+
error: `Base ref "${baseRef}" was not found.`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const currentTranslations = await readAllTranslations(cfg);
|
|
69
|
+
const currentFlatByLocale = {};
|
|
70
|
+
const baseFlatByLocale = {};
|
|
71
|
+
for (const locale of cfg.locales) {
|
|
72
|
+
currentFlatByLocale[locale] = flattenObject((currentTranslations[locale] ?? {}));
|
|
73
|
+
const localeRelativePath = resolveLocaleFileRelativePath(cfg, locale, cwd);
|
|
74
|
+
const baseJson = await readJsonFromGit(cwd, resolvedBaseRef, localeRelativePath);
|
|
75
|
+
baseFlatByLocale[locale] = flattenObject(baseJson);
|
|
76
|
+
}
|
|
77
|
+
const allKeys = Array.from(new Set(cfg.locales.flatMap((locale) => [
|
|
78
|
+
...Object.keys(baseFlatByLocale[locale] ?? {}),
|
|
79
|
+
...Object.keys(currentFlatByLocale[locale] ?? {}),
|
|
80
|
+
]))).sort((left, right) => left.localeCompare(right));
|
|
81
|
+
const keys = [];
|
|
82
|
+
for (const key of allKeys) {
|
|
83
|
+
const changes = [];
|
|
84
|
+
for (const locale of cfg.locales) {
|
|
85
|
+
const before = baseFlatByLocale[locale]?.[key] ?? "";
|
|
86
|
+
const after = currentFlatByLocale[locale]?.[key] ?? "";
|
|
87
|
+
if (before === after) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const beforeMissing = before.trim() === "";
|
|
91
|
+
const afterMissing = after.trim() === "";
|
|
92
|
+
const kind = beforeMissing && !afterMissing
|
|
93
|
+
? "added"
|
|
94
|
+
: !beforeMissing && afterMissing
|
|
95
|
+
? "removed"
|
|
96
|
+
: "changed";
|
|
97
|
+
changes.push({ locale, kind, before, after });
|
|
98
|
+
}
|
|
99
|
+
if (changes.length > 0) {
|
|
100
|
+
keys.push({
|
|
101
|
+
key,
|
|
102
|
+
changes: changes.sort((left, right) => left.locale.localeCompare(right.locale)),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
available: true,
|
|
108
|
+
baseRef,
|
|
109
|
+
resolvedBaseRef,
|
|
110
|
+
generatedAt: new Date().toISOString(),
|
|
111
|
+
keys,
|
|
112
|
+
};
|
|
113
|
+
}
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
const GLOSS_HOOK_MARKER = "# gloss-pre-commit";
|
|
5
|
+
const GLOSS_HUSKY_MARKER = "# gloss-husky-hook";
|
|
6
|
+
const GLOSS_HOOK_COMMAND = "npx gloss check --format human";
|
|
7
|
+
const fileExists = async (filePath) => {
|
|
8
|
+
try {
|
|
9
|
+
await fs.access(filePath);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
const resolveGitDir = (projectDir) => {
|
|
17
|
+
try {
|
|
18
|
+
const raw = execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
19
|
+
cwd: projectDir,
|
|
20
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
21
|
+
encoding: "utf8",
|
|
22
|
+
}).trim();
|
|
23
|
+
if (!raw) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
if (path.isAbsolute(raw)) {
|
|
27
|
+
return raw;
|
|
28
|
+
}
|
|
29
|
+
return path.resolve(projectDir, raw);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
const writeExecutable = async (filePath, content) => {
|
|
36
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
37
|
+
await fs.chmod(filePath, 0o755);
|
|
38
|
+
};
|
|
39
|
+
const installGitHook = async (hookPath) => {
|
|
40
|
+
const nextSnippet = `\n${GLOSS_HOOK_MARKER}\n${GLOSS_HOOK_COMMAND}\n`;
|
|
41
|
+
if (!(await fileExists(hookPath))) {
|
|
42
|
+
await writeExecutable(hookPath, `#!/bin/sh\nset -e${nextSnippet}`);
|
|
43
|
+
return "created";
|
|
44
|
+
}
|
|
45
|
+
const existing = await fs.readFile(hookPath, "utf8");
|
|
46
|
+
if (existing.includes(GLOSS_HOOK_MARKER) || existing.includes(GLOSS_HOOK_COMMAND)) {
|
|
47
|
+
return "skipped";
|
|
48
|
+
}
|
|
49
|
+
const suffix = existing.endsWith("\n") ? nextSnippet : `\n${nextSnippet}`;
|
|
50
|
+
await writeExecutable(hookPath, `${existing}${suffix}`);
|
|
51
|
+
return "updated";
|
|
52
|
+
};
|
|
53
|
+
const installHuskyHook = async (huskyDirectoryPath) => {
|
|
54
|
+
if (!(await fileExists(huskyDirectoryPath))) {
|
|
55
|
+
return "missing";
|
|
56
|
+
}
|
|
57
|
+
const hookPath = path.join(huskyDirectoryPath, "pre-commit");
|
|
58
|
+
const huskyBootstrapPath = path.join(huskyDirectoryPath, "_", "husky.sh");
|
|
59
|
+
const huskyBootstrapLine = `. "$(dirname -- "$0")/_/husky.sh"`;
|
|
60
|
+
const nextSnippet = `\n${GLOSS_HUSKY_MARKER}\n${GLOSS_HOOK_COMMAND}\n`;
|
|
61
|
+
if (!(await fileExists(hookPath))) {
|
|
62
|
+
const bootstrap = (await fileExists(huskyBootstrapPath))
|
|
63
|
+
? `${huskyBootstrapLine}\n\n`
|
|
64
|
+
: "";
|
|
65
|
+
await writeExecutable(hookPath, `#!/usr/bin/env sh\n${bootstrap}${nextSnippet}`);
|
|
66
|
+
return "created";
|
|
67
|
+
}
|
|
68
|
+
const existing = await fs.readFile(hookPath, "utf8");
|
|
69
|
+
if (existing.includes(GLOSS_HUSKY_MARKER) || existing.includes(GLOSS_HOOK_COMMAND)) {
|
|
70
|
+
return "skipped";
|
|
71
|
+
}
|
|
72
|
+
const suffix = existing.endsWith("\n") ? nextSnippet : `\n${nextSnippet}`;
|
|
73
|
+
await writeExecutable(hookPath, `${existing}${suffix}`);
|
|
74
|
+
return "updated";
|
|
75
|
+
};
|
|
76
|
+
export async function installPreCommitHooks(projectDir, options) {
|
|
77
|
+
const messages = [];
|
|
78
|
+
const gitDir = options?.gitDirPath === undefined
|
|
79
|
+
? resolveGitDir(projectDir)
|
|
80
|
+
: options.gitDirPath;
|
|
81
|
+
let gitHook = "missing";
|
|
82
|
+
if (gitDir) {
|
|
83
|
+
const hooksDir = path.join(gitDir, "hooks");
|
|
84
|
+
await fs.mkdir(hooksDir, { recursive: true });
|
|
85
|
+
gitHook = await installGitHook(path.join(hooksDir, "pre-commit"));
|
|
86
|
+
messages.push(`.git hook: ${gitHook}`);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
messages.push(".git hook: missing (not a git repository)");
|
|
90
|
+
}
|
|
91
|
+
const huskyHook = await installHuskyHook(path.join(projectDir, ".husky"));
|
|
92
|
+
messages.push(`husky hook: ${huskyHook}`);
|
|
93
|
+
if (gitHook === "missing" && huskyHook === "missing") {
|
|
94
|
+
messages.push("No hook target found. Initialize git or husky first.");
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
gitHook,
|
|
98
|
+
huskyHook,
|
|
99
|
+
messages,
|
|
100
|
+
};
|
|
101
|
+
}
|