anylang-dev 0.1.3 → 0.2.1
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 +52 -15
- package/package.json +16 -2
- package/src/config.js +4 -0
- package/src/extract.js +17 -0
- package/src/jsx-runtime.d.ts +7 -0
- package/src/jsx.js +179 -0
- package/src/pipeline.js +5 -0
- package/src/vite.d.ts +8 -0
- package/src/vite.js +25 -0
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# anylang-dev
|
|
2
2
|
|
|
3
|
-
`anylang-dev` is a
|
|
3
|
+
`anylang-dev` is a bring-your-own-key website translation CLI and Vite plugin. It scans your source code, writes JSON locale files, and can automatically translate static JSX text.
|
|
4
4
|
|
|
5
|
-
```
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
```tsx
|
|
6
|
+
<h1>Translate your website with anylang</h1>
|
|
7
|
+
<p tr="false">This text stays as it is</p>
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
For dynamic text, use the generated `useTr` hook:
|
|
11
11
|
|
|
12
12
|
```tsx
|
|
13
13
|
export function Hero() {
|
|
@@ -15,11 +15,9 @@ export function Hero() {
|
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
17
|
<section>
|
|
18
|
-
<h1>
|
|
19
|
-
<button
|
|
20
|
-
|
|
21
|
-
</button>
|
|
22
|
-
<p>This plain JSX text stays as it is.</p>
|
|
18
|
+
<h1>Welcome back</h1>
|
|
19
|
+
<button>{$tr("actions.save", "Save")}</button>
|
|
20
|
+
<p tr="false">BrandName</p>
|
|
23
21
|
</section>
|
|
24
22
|
);
|
|
25
23
|
}
|
|
@@ -62,6 +60,30 @@ anylang init
|
|
|
62
60
|
anylang scan
|
|
63
61
|
```
|
|
64
62
|
|
|
63
|
+
Add the Vite plugin:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { defineConfig } from "vite";
|
|
67
|
+
import react from "@vitejs/plugin-react";
|
|
68
|
+
import anylang from "anylang-dev/vite";
|
|
69
|
+
|
|
70
|
+
export default defineConfig({
|
|
71
|
+
plugins: [anylang(), react()],
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
If you use `tr="false"`, add the JSX type augmentation once in `src/vite-env.d.ts`:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import "anylang-dev/jsx-runtime";
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
That makes this TypeScript-safe:
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
<p tr="false">BrandName</p>
|
|
85
|
+
```
|
|
86
|
+
|
|
65
87
|
`anylang scan` creates locale files without calling a translation provider. To translate for real with Gemini, add your own API key to `.env` in the project where you run `anylang`:
|
|
66
88
|
|
|
67
89
|
```env
|
|
@@ -90,6 +112,10 @@ anylang translate
|
|
|
90
112
|
"importFrom": "anylang-dev/runtime"
|
|
91
113
|
},
|
|
92
114
|
"functionName": "$tr",
|
|
115
|
+
"autoTranslate": {
|
|
116
|
+
"jsx": true,
|
|
117
|
+
"keyPrefix": "auto"
|
|
118
|
+
},
|
|
93
119
|
"provider": {
|
|
94
120
|
"name": "gemini",
|
|
95
121
|
"model": "gemini-2.5-flash"
|
|
@@ -159,12 +185,15 @@ The lock file stores SHA-256 fingerprints so unchanged strings are skipped on la
|
|
|
159
185
|
|
|
160
186
|
## Workflow
|
|
161
187
|
|
|
162
|
-
1.
|
|
188
|
+
1. Write normal static JSX text:
|
|
163
189
|
|
|
164
190
|
```tsx
|
|
165
|
-
<h1>
|
|
191
|
+
<h1>Translate your website with anylang</h1>
|
|
192
|
+
<p tr="false">Do not translate this text</p>
|
|
166
193
|
```
|
|
167
194
|
|
|
195
|
+
Use `$tr("key", "source text")` only for dynamic or special cases.
|
|
196
|
+
|
|
168
197
|
2. Scan the project:
|
|
169
198
|
|
|
170
199
|
```bash
|
|
@@ -190,7 +219,7 @@ Source locale output:
|
|
|
190
219
|
|
|
191
220
|
```json
|
|
192
221
|
{
|
|
193
|
-
"
|
|
222
|
+
"auto.src_app.translate_your_website_with_anylang_a1b2c3d4": {
|
|
194
223
|
"text": "Translate your website with anylang",
|
|
195
224
|
"variables": []
|
|
196
225
|
}
|
|
@@ -201,7 +230,7 @@ Target locale output:
|
|
|
201
230
|
|
|
202
231
|
```json
|
|
203
232
|
{
|
|
204
|
-
"
|
|
233
|
+
"auto.src_app.translate_your_website_with_anylang_a1b2c3d4": {
|
|
205
234
|
"source": "Translate your website with anylang",
|
|
206
235
|
"text": "anylang से अपनी वेबसाइट का अनुवाद करें",
|
|
207
236
|
"variables": []
|
|
@@ -240,8 +269,16 @@ Then use translations in any component:
|
|
|
240
269
|
|
|
241
270
|
```tsx
|
|
242
271
|
function Hero() {
|
|
272
|
+
return <h1>Translate your website with anylang</h1>;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
For dynamic text:
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
function SaveButton() {
|
|
243
280
|
const $tr = useTr();
|
|
244
|
-
return <
|
|
281
|
+
return <button>{$tr("actions.save", "Save")}</button>;
|
|
245
282
|
}
|
|
246
283
|
```
|
|
247
284
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "anylang-dev",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Bring-your-own-key website translation JSON generator.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"files": [
|
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
"./runtime": {
|
|
18
18
|
"types": "./src/runtime.d.ts",
|
|
19
19
|
"default": "./src/runtime.js"
|
|
20
|
+
},
|
|
21
|
+
"./vite": {
|
|
22
|
+
"types": "./src/vite.d.ts",
|
|
23
|
+
"default": "./src/vite.js"
|
|
24
|
+
},
|
|
25
|
+
"./jsx-runtime": {
|
|
26
|
+
"types": "./src/jsx-runtime.d.ts",
|
|
27
|
+
"default": "./src/runtime.js"
|
|
20
28
|
}
|
|
21
29
|
},
|
|
22
30
|
"scripts": {
|
|
@@ -37,5 +45,11 @@
|
|
|
37
45
|
"type": "git",
|
|
38
46
|
"url": "git+ssh://git@github.com/akshaywritescode/anylang-dev.git"
|
|
39
47
|
},
|
|
40
|
-
"license": "MIT"
|
|
48
|
+
"license": "MIT",
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@babel/generator": "^7.29.7",
|
|
51
|
+
"@babel/parser": "^7.29.7",
|
|
52
|
+
"@babel/traverse": "^7.29.7",
|
|
53
|
+
"@babel/types": "^7.29.7"
|
|
54
|
+
}
|
|
41
55
|
}
|
package/src/config.js
CHANGED
|
@@ -12,6 +12,10 @@ export const DEFAULT_CONFIG = {
|
|
|
12
12
|
importFrom: "anylang-dev/runtime"
|
|
13
13
|
},
|
|
14
14
|
functionName: "$tr",
|
|
15
|
+
autoTranslate: {
|
|
16
|
+
jsx: true,
|
|
17
|
+
keyPrefix: "auto"
|
|
18
|
+
},
|
|
15
19
|
provider: {
|
|
16
20
|
name: "gemini",
|
|
17
21
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta",
|
package/src/extract.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readdir, readFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { extractAutoJsxStrings } from "./jsx.js";
|
|
3
4
|
|
|
4
5
|
const DEFAULT_EXTENSIONS = new Set([".js", ".jsx", ".ts", ".tsx", ".vue", ".html", ".svelte", ".astro"]);
|
|
5
6
|
|
|
@@ -20,11 +21,27 @@ export async function extractProjectStrings(config) {
|
|
|
20
21
|
...lineColumnForIndex(source, match.index)
|
|
21
22
|
});
|
|
22
23
|
}
|
|
24
|
+
if (config.autoTranslate?.jsx !== false && isJsxFile(file)) {
|
|
25
|
+
for (const match of extractAutoJsxStrings(source, file, config.autoTranslate || {})) {
|
|
26
|
+
const key = `${match.key}\0${file}\0${match.index}`;
|
|
27
|
+
if (seen.has(key)) continue;
|
|
28
|
+
seen.add(key);
|
|
29
|
+
items.push({
|
|
30
|
+
...match,
|
|
31
|
+
file,
|
|
32
|
+
...lineColumnForIndex(source, match.index)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
23
36
|
}
|
|
24
37
|
|
|
25
38
|
return { files, items };
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
function isJsxFile(file) {
|
|
42
|
+
return /\.(jsx|tsx)$/.test(file);
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
export function extractFromSource(source, functionName = "$tr") {
|
|
29
46
|
const matches = [];
|
|
30
47
|
let index = 0;
|
package/src/jsx.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "@babel/parser";
|
|
4
|
+
import generate from "@babel/generator";
|
|
5
|
+
import traverseModule from "@babel/traverse";
|
|
6
|
+
import * as t from "@babel/types";
|
|
7
|
+
|
|
8
|
+
const traverse = traverseModule.default || traverseModule;
|
|
9
|
+
export function extractAutoJsxStrings(source, filePath, options = {}) {
|
|
10
|
+
const ast = parseJsx(source, filePath);
|
|
11
|
+
const items = [];
|
|
12
|
+
|
|
13
|
+
traverse(ast, {
|
|
14
|
+
JSXElement(pathRef) {
|
|
15
|
+
if (isTranslationDisabled(pathRef.node.openingElement)) {
|
|
16
|
+
pathRef.skip();
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
JSXText(pathRef) {
|
|
20
|
+
if (hasDisabledAncestor(pathRef)) return;
|
|
21
|
+
const text = normalizeJsxText(pathRef.node.value);
|
|
22
|
+
if (!isTranslatableText(text)) return;
|
|
23
|
+
|
|
24
|
+
items.push(autoItem({
|
|
25
|
+
text,
|
|
26
|
+
filePath,
|
|
27
|
+
index: pathRef.node.start || 0,
|
|
28
|
+
prefix: options.keyPrefix
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return items;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function transformAutoJsx(source, filePath, options = {}) {
|
|
37
|
+
const ast = parseJsx(source, filePath);
|
|
38
|
+
let changed = false;
|
|
39
|
+
|
|
40
|
+
traverse(ast, {
|
|
41
|
+
JSXElement(pathRef) {
|
|
42
|
+
if (isTranslationDisabled(pathRef.node.openingElement)) {
|
|
43
|
+
removeTrFalseAttribute(pathRef.node.openingElement);
|
|
44
|
+
pathRef.skip();
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
JSXText(pathRef) {
|
|
48
|
+
if (hasDisabledAncestor(pathRef)) return;
|
|
49
|
+
const text = normalizeJsxText(pathRef.node.value);
|
|
50
|
+
if (!isTranslatableText(text)) return;
|
|
51
|
+
|
|
52
|
+
pathRef.replaceWith(t.jsxExpressionContainer(anyLangTextElement({
|
|
53
|
+
key: autoKey(text, filePath, options.keyPrefix),
|
|
54
|
+
text
|
|
55
|
+
})));
|
|
56
|
+
changed = true;
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (!changed) return { code: source, changed: false };
|
|
61
|
+
ensureAnyLangTextImport(ast, options.runtimeImport || "/src/anylang.ts");
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
code: generate.default(ast, { retainLines: true }, source).code,
|
|
65
|
+
changed: true
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseJsx(source, filePath) {
|
|
70
|
+
return parse(source, {
|
|
71
|
+
sourceType: "module",
|
|
72
|
+
sourceFilename: filePath,
|
|
73
|
+
plugins: ["jsx", "typescript"]
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function autoItem({ text, filePath, index, prefix }) {
|
|
78
|
+
return {
|
|
79
|
+
key: autoKey(text, filePath, prefix),
|
|
80
|
+
value: text,
|
|
81
|
+
variables: [],
|
|
82
|
+
index,
|
|
83
|
+
raw: text,
|
|
84
|
+
auto: true
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function autoKey(text, filePath, prefix = "auto") {
|
|
89
|
+
const relative = path.relative(process.cwd(), filePath).split(path.sep).join("/");
|
|
90
|
+
const fileSlug = slug(relative.replace(/\.[^.]+$/, ""));
|
|
91
|
+
const textSlug = slug(text).slice(0, 36) || "text";
|
|
92
|
+
const hash = createHash("sha1").update(`${relative}\0${text}`).digest("hex").slice(0, 8);
|
|
93
|
+
return `${prefix}.${fileSlug}.${textSlug}_${hash}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function slug(value) {
|
|
97
|
+
return value
|
|
98
|
+
.toLowerCase()
|
|
99
|
+
.replace(/[^a-z0-9]+/g, "_")
|
|
100
|
+
.replace(/^_+|_+$/g, "");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeJsxText(value) {
|
|
104
|
+
return value
|
|
105
|
+
.split(/\r?\n/)
|
|
106
|
+
.map((line) => line.replace(/\s+/g, " ").trim())
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(" ");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function isTranslatableText(text) {
|
|
112
|
+
if (!text) return false;
|
|
113
|
+
if (!/[A-Za-z0-9]/.test(text)) return false;
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isTranslationDisabled(openingElement) {
|
|
118
|
+
return openingElement.attributes.some((attribute) => {
|
|
119
|
+
if (!t.isJSXAttribute(attribute) || !t.isJSXIdentifier(attribute.name) || attribute.name.name !== "tr") {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
if (!attribute.value) return true;
|
|
123
|
+
if (t.isStringLiteral(attribute.value)) return attribute.value.value === "false";
|
|
124
|
+
return (
|
|
125
|
+
t.isJSXExpressionContainer(attribute.value) &&
|
|
126
|
+
t.isBooleanLiteral(attribute.value.expression) &&
|
|
127
|
+
attribute.value.expression.value === false
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function removeTrFalseAttribute(openingElement) {
|
|
133
|
+
openingElement.attributes = openingElement.attributes.filter((attribute) => {
|
|
134
|
+
return !(t.isJSXAttribute(attribute) && t.isJSXIdentifier(attribute.name) && attribute.name.name === "tr");
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function hasDisabledAncestor(pathRef) {
|
|
139
|
+
return Boolean(pathRef.findParent((parent) => {
|
|
140
|
+
return parent.isJSXElement() && isTranslationDisabled(parent.node.openingElement);
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function anyLangTextElement({ key, text }) {
|
|
145
|
+
return t.jsxElement(
|
|
146
|
+
t.jsxOpeningElement(t.jsxIdentifier("AnyLangText"), [
|
|
147
|
+
t.jsxAttribute(t.jsxIdentifier("k"), t.stringLiteral(key)),
|
|
148
|
+
t.jsxAttribute(t.jsxIdentifier("source"), t.stringLiteral(text))
|
|
149
|
+
], true),
|
|
150
|
+
null,
|
|
151
|
+
[],
|
|
152
|
+
true
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ensureAnyLangTextImport(ast, runtimeImport) {
|
|
157
|
+
const body = ast.program.body;
|
|
158
|
+
const existing = body.find((node) => {
|
|
159
|
+
return t.isImportDeclaration(node) && node.source.value === runtimeImport;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (existing) {
|
|
163
|
+
ensureSpecifier(existing, "AnyLangText");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
body.unshift(t.importDeclaration([
|
|
168
|
+
t.importSpecifier(t.identifier("AnyLangText"), t.identifier("AnyLangText"))
|
|
169
|
+
], t.stringLiteral(runtimeImport)));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function ensureSpecifier(importDeclaration, name) {
|
|
173
|
+
const hasSpecifier = importDeclaration.specifiers.some((specifier) => {
|
|
174
|
+
return t.isImportSpecifier(specifier) && specifier.imported.name === name;
|
|
175
|
+
});
|
|
176
|
+
if (!hasSpecifier) {
|
|
177
|
+
importDeclaration.specifiers.push(t.importSpecifier(t.identifier(name), t.identifier(name)));
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/pipeline.js
CHANGED
|
@@ -233,6 +233,11 @@ export function useTr() {
|
|
|
233
233
|
if (!context) throw new Error('useTr must be used inside AnyLangProvider')
|
|
234
234
|
return context.$tr
|
|
235
235
|
}
|
|
236
|
+
|
|
237
|
+
export function AnyLangText({ k, source }: { k: string; source: string }) {
|
|
238
|
+
const $tr = useTr()
|
|
239
|
+
return $tr(k, source)
|
|
240
|
+
}
|
|
236
241
|
`;
|
|
237
242
|
}
|
|
238
243
|
|
package/src/vite.d.ts
ADDED
package/src/vite.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { transformAutoJsx } from "./jsx.js";
|
|
2
|
+
|
|
3
|
+
const JSX_EXTENSIONS = /\.(jsx|tsx)$/;
|
|
4
|
+
|
|
5
|
+
export default function anylang(options = {}) {
|
|
6
|
+
return {
|
|
7
|
+
name: "anylang-dev",
|
|
8
|
+
enforce: "pre",
|
|
9
|
+
transform(code, id) {
|
|
10
|
+
if (!JSX_EXTENSIONS.test(id)) return null;
|
|
11
|
+
if (id.includes("node_modules")) return null;
|
|
12
|
+
|
|
13
|
+
const result = transformAutoJsx(code, id, {
|
|
14
|
+
keyPrefix: options.keyPrefix,
|
|
15
|
+
runtimeImport: options.runtimeImport || "@/anylang"
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
if (!result.changed) return null;
|
|
19
|
+
return {
|
|
20
|
+
code: result.code,
|
|
21
|
+
map: null
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|