@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/server.js
CHANGED
|
@@ -3,9 +3,14 @@ import path from "node:path";
|
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import express from "express";
|
|
5
5
|
import cors from "cors";
|
|
6
|
-
import {
|
|
6
|
+
import { updateIssueBaseline } from "./baseline.js";
|
|
7
|
+
import { runGlossCheck } from "./check.js";
|
|
8
|
+
import { readAllTranslations, WriteLockError, writeAllTranslations } from "./fs.js";
|
|
9
|
+
import { buildGitKeyDiff } from "./gitDiff.js";
|
|
10
|
+
import { flattenObject, unflattenObject } from "./translationTree.js";
|
|
7
11
|
import { buildKeyUsageMap } from "./usage.js";
|
|
8
12
|
import { inferUsageRoot, scanUsage } from "./usageScanner.js";
|
|
13
|
+
import { buildXliffDocument, parseXliffTargets } from "./xliff.js";
|
|
9
14
|
import { renameKeyUsage } from "./renameKeyUsage.js";
|
|
10
15
|
const resolveUiDistPath = () => {
|
|
11
16
|
const runtimeDir = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -23,26 +28,175 @@ const resolveUiDistPath = () => {
|
|
|
23
28
|
return null;
|
|
24
29
|
};
|
|
25
30
|
export function createServerApp(cfg) {
|
|
31
|
+
return createServerAppWithOptions(cfg);
|
|
32
|
+
}
|
|
33
|
+
const shouldBypassCache = (value) => {
|
|
34
|
+
if (typeof value !== "string") {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
38
|
+
};
|
|
39
|
+
export function createServerAppWithOptions(cfg, runtimeOptions = {}) {
|
|
26
40
|
const app = express();
|
|
27
41
|
app.use(cors());
|
|
28
42
|
app.use(express.json({ limit: "5mb" }));
|
|
43
|
+
const defaultUseCache = runtimeOptions.useCache !== false;
|
|
44
|
+
const requestUseCache = (req) => {
|
|
45
|
+
if (!defaultUseCache) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const noCacheValue = req.query.noCache;
|
|
49
|
+
if (Array.isArray(noCacheValue)) {
|
|
50
|
+
return !noCacheValue.some((entry) => shouldBypassCache(entry));
|
|
51
|
+
}
|
|
52
|
+
return !shouldBypassCache(noCacheValue);
|
|
53
|
+
};
|
|
29
54
|
app.get("/api/config", (_req, res) => res.json(cfg));
|
|
30
55
|
app.get("/api/translations", async (_req, res) => {
|
|
31
56
|
const data = await readAllTranslations(cfg);
|
|
32
57
|
res.json(data);
|
|
33
58
|
});
|
|
34
59
|
app.get("/api/usage", async (_req, res) => {
|
|
35
|
-
const usage = await scanUsage(inferUsageRoot(cfg), cfg.scan
|
|
60
|
+
const usage = await scanUsage(inferUsageRoot(cfg), cfg.scan, {
|
|
61
|
+
useCache: requestUseCache(_req),
|
|
62
|
+
});
|
|
36
63
|
res.json(usage);
|
|
37
64
|
});
|
|
38
|
-
app.get("/api/key-usage", async (
|
|
39
|
-
const usage = await buildKeyUsageMap(cfg
|
|
65
|
+
app.get("/api/key-usage", async (req, res) => {
|
|
66
|
+
const usage = await buildKeyUsageMap(cfg, {
|
|
67
|
+
useCache: requestUseCache(req),
|
|
68
|
+
});
|
|
40
69
|
res.json(usage);
|
|
41
70
|
});
|
|
71
|
+
app.get("/api/check", async (req, res) => {
|
|
72
|
+
const result = await runGlossCheck(cfg, {
|
|
73
|
+
useCache: requestUseCache(req),
|
|
74
|
+
});
|
|
75
|
+
let baseline = null;
|
|
76
|
+
try {
|
|
77
|
+
baseline = await updateIssueBaseline(process.env.INIT_CWD || process.cwd(), result.summary);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
baseline = null;
|
|
81
|
+
}
|
|
82
|
+
const summaryValue = typeof req.query.summary === "string" ? req.query.summary : "";
|
|
83
|
+
const summaryOnly = summaryValue === "1" || summaryValue === "true";
|
|
84
|
+
if (summaryOnly) {
|
|
85
|
+
res.json({
|
|
86
|
+
ok: result.ok,
|
|
87
|
+
generatedAt: result.generatedAt,
|
|
88
|
+
summary: result.summary,
|
|
89
|
+
hardcodedTexts: result.hardcodedTexts.slice(0, 20),
|
|
90
|
+
baseline,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
res.json({ ...result, baseline });
|
|
95
|
+
});
|
|
96
|
+
app.get("/api/git-diff", async (req, res) => {
|
|
97
|
+
const base = typeof req.query.base === "string" && req.query.base.trim()
|
|
98
|
+
? req.query.base.trim()
|
|
99
|
+
: "origin/main";
|
|
100
|
+
const diff = await buildGitKeyDiff(cfg, base);
|
|
101
|
+
res.json(diff);
|
|
102
|
+
});
|
|
103
|
+
app.get("/api/xliff/export", async (req, res) => {
|
|
104
|
+
const locale = typeof req.query.locale === "string" ? req.query.locale.trim() : "";
|
|
105
|
+
const sourceLocale = typeof req.query.sourceLocale === "string" && req.query.sourceLocale.trim()
|
|
106
|
+
? req.query.sourceLocale.trim()
|
|
107
|
+
: cfg.defaultLocale;
|
|
108
|
+
if (!locale || !cfg.locales.includes(locale)) {
|
|
109
|
+
res.status(400).json({
|
|
110
|
+
ok: false,
|
|
111
|
+
error: "Query parameter `locale` is required and must be one of configured locales.",
|
|
112
|
+
});
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (!cfg.locales.includes(sourceLocale)) {
|
|
116
|
+
res.status(400).json({
|
|
117
|
+
ok: false,
|
|
118
|
+
error: "Query parameter `sourceLocale` must be one of configured locales when provided.",
|
|
119
|
+
});
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const data = await readAllTranslations(cfg);
|
|
123
|
+
const xml = buildXliffDocument({
|
|
124
|
+
translations: data,
|
|
125
|
+
locales: cfg.locales,
|
|
126
|
+
sourceLocale,
|
|
127
|
+
targetLocale: locale,
|
|
128
|
+
});
|
|
129
|
+
res.setHeader("Content-Type", "application/xliff+xml; charset=utf-8");
|
|
130
|
+
res.setHeader("Content-Disposition", `attachment; filename="gloss-${locale}.xlf"`);
|
|
131
|
+
res.send(xml);
|
|
132
|
+
});
|
|
133
|
+
app.post("/api/xliff/import", async (req, res) => {
|
|
134
|
+
const locale = typeof req.query.locale === "string" ? req.query.locale.trim() : "";
|
|
135
|
+
if (!locale || !cfg.locales.includes(locale)) {
|
|
136
|
+
res.status(400).json({
|
|
137
|
+
ok: false,
|
|
138
|
+
error: "Query parameter `locale` is required and must be one of configured locales.",
|
|
139
|
+
});
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const body = req.body;
|
|
143
|
+
if (typeof body.content !== "string" || body.content.trim().length === 0) {
|
|
144
|
+
res.status(400).json({
|
|
145
|
+
ok: false,
|
|
146
|
+
error: "Request body must include non-empty `content` string.",
|
|
147
|
+
});
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const parsedTargets = parseXliffTargets(body.content);
|
|
152
|
+
const updates = Object.entries(parsedTargets).filter(([key]) => key.trim().length > 0);
|
|
153
|
+
if (updates.length === 0) {
|
|
154
|
+
res.status(400).json({
|
|
155
|
+
ok: false,
|
|
156
|
+
error: "No translatable units found in XLIFF content.",
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const data = await readAllTranslations(cfg);
|
|
161
|
+
const localeFlat = flattenObject(data[locale] ?? {});
|
|
162
|
+
for (const [key, value] of updates) {
|
|
163
|
+
localeFlat[key] = value;
|
|
164
|
+
}
|
|
165
|
+
data[locale] = unflattenObject(localeFlat);
|
|
166
|
+
await writeAllTranslations(cfg, data);
|
|
167
|
+
res.json({
|
|
168
|
+
ok: true,
|
|
169
|
+
locale,
|
|
170
|
+
updated: updates.length,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (error instanceof WriteLockError) {
|
|
175
|
+
res.status(409).json({ ok: false, error: error.message });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
res.status(400).json({
|
|
179
|
+
ok: false,
|
|
180
|
+
error: error.message || "Failed to parse XLIFF content.",
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
});
|
|
42
184
|
app.post("/api/translations", async (req, res) => {
|
|
43
185
|
const data = req.body;
|
|
44
|
-
|
|
45
|
-
|
|
186
|
+
try {
|
|
187
|
+
await writeAllTranslations(cfg, data);
|
|
188
|
+
res.json({ ok: true });
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error instanceof WriteLockError) {
|
|
192
|
+
res.status(409).json({ ok: false, error: error.message });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
res.status(500).json({
|
|
196
|
+
ok: false,
|
|
197
|
+
error: "Failed to write translation files.",
|
|
198
|
+
});
|
|
199
|
+
}
|
|
46
200
|
});
|
|
47
201
|
app.post("/api/rename-key", async (req, res) => {
|
|
48
202
|
const body = req.body;
|
|
@@ -56,7 +210,7 @@ export function createServerApp(cfg) {
|
|
|
56
210
|
return;
|
|
57
211
|
}
|
|
58
212
|
try {
|
|
59
|
-
const result = await renameKeyUsage(oldKey, newKey);
|
|
213
|
+
const result = await renameKeyUsage(oldKey, newKey, undefined, cfg.scan?.mode);
|
|
60
214
|
res.json({ ok: true, ...result });
|
|
61
215
|
}
|
|
62
216
|
catch (error) {
|
|
@@ -81,8 +235,8 @@ export function createServerApp(cfg) {
|
|
|
81
235
|
}
|
|
82
236
|
return app;
|
|
83
237
|
}
|
|
84
|
-
export async function startServer(cfg, port = 5179) {
|
|
85
|
-
const app =
|
|
238
|
+
export async function startServer(cfg, port = 5179, runtimeOptions = {}) {
|
|
239
|
+
const app = createServerAppWithOptions(cfg, runtimeOptions);
|
|
86
240
|
const server = await new Promise((resolve) => {
|
|
87
241
|
const nextServer = app.listen(port, () => resolve(nextServer));
|
|
88
242
|
});
|
package/dist/translationKeys.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/-]*$/;
|
|
2
|
+
const KEY_CHAR_PATTERN = /^[A-Za-z0-9._:/-]+$/;
|
|
2
3
|
export const isLikelyTranslationKey = (value) => {
|
|
3
4
|
const key = value.trim();
|
|
4
5
|
if (key.length === 0 || key.length > 160) {
|
|
@@ -15,3 +16,22 @@ export const isLikelyTranslationKey = (value) => {
|
|
|
15
16
|
}
|
|
16
17
|
return TRANSLATION_KEY_PATTERN.test(key);
|
|
17
18
|
};
|
|
19
|
+
export const getInvalidTranslationKeyReason = (value) => {
|
|
20
|
+
const key = value.trim();
|
|
21
|
+
if (!key) {
|
|
22
|
+
return "Key is empty.";
|
|
23
|
+
}
|
|
24
|
+
if (key.startsWith(".") || key.endsWith(".")) {
|
|
25
|
+
return "Key cannot start or end with a dot.";
|
|
26
|
+
}
|
|
27
|
+
if (key.includes("..")) {
|
|
28
|
+
return "Key cannot contain consecutive dots.";
|
|
29
|
+
}
|
|
30
|
+
if (key.split(".").some((segment) => segment.trim() === "")) {
|
|
31
|
+
return "Key contains an empty segment.";
|
|
32
|
+
}
|
|
33
|
+
if (!KEY_CHAR_PATTERN.test(key)) {
|
|
34
|
+
return "Key contains unsupported characters.";
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2
|
+
export const flattenObject = (obj) => {
|
|
3
|
+
const result = {};
|
|
4
|
+
const visit = (node, path) => {
|
|
5
|
+
for (const [key, value] of Object.entries(node)) {
|
|
6
|
+
const nextPath = path ? `${path}.${key}` : key;
|
|
7
|
+
if (isPlainObject(value)) {
|
|
8
|
+
visit(value, nextPath);
|
|
9
|
+
continue;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === "string") {
|
|
12
|
+
result[nextPath] = value;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (value !== undefined) {
|
|
16
|
+
result[nextPath] = String(value);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
visit(obj, "");
|
|
21
|
+
return result;
|
|
22
|
+
};
|
|
23
|
+
export const unflattenObject = (flat) => {
|
|
24
|
+
const root = {};
|
|
25
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
26
|
+
const parts = key.split(".").filter((part) => part.length > 0);
|
|
27
|
+
if (parts.length === 0) {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
let cursor = root;
|
|
31
|
+
for (let index = 0; index < parts.length - 1; index += 1) {
|
|
32
|
+
const part = parts[index];
|
|
33
|
+
const existing = cursor[part];
|
|
34
|
+
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
|
35
|
+
cursor[part] = {};
|
|
36
|
+
}
|
|
37
|
+
cursor = cursor[part];
|
|
38
|
+
}
|
|
39
|
+
cursor[parts[parts.length - 1]] = value;
|
|
40
|
+
}
|
|
41
|
+
return root;
|
|
42
|
+
};
|
package/dist/typegen.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readAllTranslations } from "./fs.js";
|
|
4
|
+
import { flattenObject } from "./translationTree.js";
|
|
5
|
+
const projectRoot = () => process.env.INIT_CWD || process.cwd();
|
|
6
|
+
const renderTypeFile = (keys) => {
|
|
7
|
+
const keyUnion = keys.length === 0
|
|
8
|
+
? "never"
|
|
9
|
+
: keys.map((key) => ` | ${JSON.stringify(key)}`).join("\n");
|
|
10
|
+
return `/* This file is auto-generated by Gloss. Do not edit manually. */
|
|
11
|
+
|
|
12
|
+
export type I18nKey =
|
|
13
|
+
${keyUnion};
|
|
14
|
+
|
|
15
|
+
export type TranslateFn = (
|
|
16
|
+
key: I18nKey,
|
|
17
|
+
variables?: Record<string, string | number>,
|
|
18
|
+
) => string;
|
|
19
|
+
|
|
20
|
+
export type GlossI18nKey = I18nKey;
|
|
21
|
+
`;
|
|
22
|
+
};
|
|
23
|
+
export async function generateKeyTypes(cfg, options = {}) {
|
|
24
|
+
const data = await readAllTranslations(cfg);
|
|
25
|
+
const keys = Array.from(new Set(Object.values(data).flatMap((tree) => Object.keys(flattenObject((tree ?? {})))))).sort((left, right) => left.localeCompare(right));
|
|
26
|
+
const outFile = path.resolve(projectRoot(), options.outFile ?? "i18n-keys.d.ts");
|
|
27
|
+
const content = renderTypeFile(keys);
|
|
28
|
+
await fs.writeFile(outFile, content, "utf8");
|
|
29
|
+
return { outFile, keyCount: keys.length };
|
|
30
|
+
}
|
|
Binary file
|