@mxml3gend/gloss 0.1.2 → 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 +22 -0
- package/dist/baseline.js +118 -0
- package/dist/cache.js +78 -0
- package/dist/cacheMetrics.js +120 -0
- package/dist/check.js +153 -25
- package/dist/config.js +79 -4
- package/dist/fs.js +105 -6
- package/dist/hooks.js +101 -0
- package/dist/index.js +262 -6
- package/dist/server.js +140 -10
- package/dist/translationTree.js +20 -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/usage.js +107 -6
- package/dist/usageScanner.js +108 -15
- package/dist/xliff.js +92 -0
- package/package.json +3 -2
- package/dist/ui/assets/index-CgyZVU2h.css +0 -1
- package/dist/ui/assets/index-DfgO64nU.js +0 -12
package/dist/config.js
CHANGED
|
@@ -33,6 +33,65 @@ const normalizeScanConfig = (value) => {
|
|
|
33
33
|
}
|
|
34
34
|
return { include, exclude, mode };
|
|
35
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
|
+
};
|
|
94
|
+
};
|
|
36
95
|
const CONFIG_FILE_NAMES = [
|
|
37
96
|
"gloss.config.ts",
|
|
38
97
|
"gloss.config.mts",
|
|
@@ -95,7 +154,7 @@ const discoverLocales = async (cwd, localesPath) => {
|
|
|
95
154
|
return [];
|
|
96
155
|
}
|
|
97
156
|
};
|
|
98
|
-
const
|
|
157
|
+
const discoverLocaleDirectoryCandidatesInternal = async (cwd) => {
|
|
99
158
|
const candidates = [];
|
|
100
159
|
const visit = async (directory) => {
|
|
101
160
|
const entries = await fs.readdir(directory, { withFileTypes: true });
|
|
@@ -129,7 +188,7 @@ const discoverLocaleDirectoryCandidates = async (cwd) => {
|
|
|
129
188
|
await visit(cwd);
|
|
130
189
|
return candidates;
|
|
131
190
|
};
|
|
132
|
-
const
|
|
191
|
+
const scoreLocaleDirectoryCandidates = (candidates, preferredLocales) => {
|
|
133
192
|
const scored = candidates.map((candidate) => {
|
|
134
193
|
const localeMatches = preferredLocales.filter((locale) => candidate.locales.includes(locale));
|
|
135
194
|
const allPreferredMatch = preferredLocales.length > 0 &&
|
|
@@ -157,7 +216,10 @@ const selectLocaleDirectoryCandidate = (candidates, preferredLocales) => {
|
|
|
157
216
|
}
|
|
158
217
|
return left.candidate.directoryPath.localeCompare(right.candidate.directoryPath);
|
|
159
218
|
});
|
|
160
|
-
return scored
|
|
219
|
+
return scored;
|
|
220
|
+
};
|
|
221
|
+
const selectLocaleDirectoryCandidate = (candidates, preferredLocales) => {
|
|
222
|
+
return scoreLocaleDirectoryCandidates(candidates, preferredLocales)[0]?.candidate;
|
|
161
223
|
};
|
|
162
224
|
const resolveDiscoveredPath = (cwd, directoryPath) => {
|
|
163
225
|
const relative = path.relative(cwd, directoryPath);
|
|
@@ -188,6 +250,17 @@ const normalizeLoadedConfig = (value) => {
|
|
|
188
250
|
}
|
|
189
251
|
return value;
|
|
190
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
|
+
}
|
|
191
264
|
export async function loadGlossConfig() {
|
|
192
265
|
const cwd = process.env.INIT_CWD || process.cwd();
|
|
193
266
|
const configPath = await resolveConfigPath(cwd);
|
|
@@ -218,7 +291,7 @@ export async function loadGlossConfig() {
|
|
|
218
291
|
const configuredPath = typeof cfg.path === "string" ? cfg.path.trim() : "";
|
|
219
292
|
const discoveredDirectoryCandidate = configuredPath
|
|
220
293
|
? null
|
|
221
|
-
: selectLocaleDirectoryCandidate(await
|
|
294
|
+
: selectLocaleDirectoryCandidate(await discoverLocaleDirectoryCandidatesInternal(cwd), configuredLocales);
|
|
222
295
|
const translationsPath = configuredPath
|
|
223
296
|
? configuredPath
|
|
224
297
|
: discoveredDirectoryCandidate
|
|
@@ -253,7 +326,9 @@ export async function loadGlossConfig() {
|
|
|
253
326
|
defaultLocale,
|
|
254
327
|
path: translationsPath,
|
|
255
328
|
format: "json",
|
|
329
|
+
strictPlaceholders: normalizeStrictPlaceholders(cfg.strictPlaceholders),
|
|
256
330
|
scan: normalizeScanConfig(cfg.scan),
|
|
331
|
+
hardcodedText: normalizeHardcodedTextConfig(cfg.hardcodedText),
|
|
257
332
|
};
|
|
258
333
|
}
|
|
259
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/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
|
+
}
|