@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/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
- if (!include && !exclude) {
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
- return entries
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 (typeof cfg.path !== "string" || !cfg.path.trim()) {
97
- throw new GlossConfigError("INVALID_CONFIG", "`path` must be a non-empty string.");
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
- : await discoverLocales(cwd, translationsPath);
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 (typeof cfg.defaultLocale !== "string" || !cfg.defaultLocale.trim()) {
115
- throw new GlossConfigError("INVALID_CONFIG", "`defaultLocale` must be a non-empty string.");
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 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
  }
@@ -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
+ }