@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/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 discoverLocaleDirectoryCandidates = async (cwd) => {
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 selectLocaleDirectoryCandidate = (candidates, preferredLocales) => {
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[0]?.candidate;
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 discoverLocaleDirectoryCandidates(cwd), configuredLocales);
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 fs.mkdir(dir, { recursive: true });
32
- for (const locale of cfg.locales) {
33
- const file = localeFile(cfg, locale);
34
- const json = JSON.stringify(data[locale] ?? {}, null, 2) + "\n";
35
- await fs.writeFile(file, json, "utf8");
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
+ }