@wrikka/create-docs 0.2.1 → 0.2.2
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/docs/translate.md +32 -7
- package/package.json +1 -1
- package/scripts/translate.ts +159 -23
package/docs/translate.md
CHANGED
|
@@ -32,15 +32,40 @@ page, and a per-page Translate action on doc pages.
|
|
|
32
32
|
|
|
33
33
|
## How it works
|
|
34
34
|
|
|
35
|
-
1. `bunx create-docs-translate --docs
|
|
35
|
+
1. `bunx create-docs-translate --docs <dir> --locales th,ja` scans your docs
|
|
36
36
|
and writes `translation-plan.json` with one task per
|
|
37
37
|
`(document, locale)` pair.
|
|
38
|
-
2.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
locale
|
|
38
|
+
2. Pass `--apply` to run the pending tasks through an OpenAI-compatible
|
|
39
|
+
chat-completions endpoint, and `--limit N` to cap each run (cost control).
|
|
40
|
+
3. When docs are generated (e.g. pulled from GitHub and gitignored), pass
|
|
41
|
+
`--i18n <dir>` so translations are written to a tracked folder
|
|
42
|
+
(`<i18n>/<locale>/<file>`) that survives re-pulls — wire that folder into
|
|
43
|
+
your `createStaticDataSource` glob next to the pulled content.
|
|
44
|
+
4. A CI workflow (e.g. `.github/workflows/translate.yml`) runs the script,
|
|
45
|
+
then commits the translated markdown back to the repo.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# plan only
|
|
49
|
+
bunx create-docs-translate --docs docs --locales th --out plan.json
|
|
50
|
+
|
|
51
|
+
# translate up to 20 files for one collection
|
|
52
|
+
TRANSLATE_PROVIDER=openai TRANSLATE_API_KEY=sk-… \
|
|
53
|
+
bunx create-docs-translate \
|
|
54
|
+
--docs docs/docs/bun-packages \
|
|
55
|
+
--i18n i18n/bun-packages \
|
|
56
|
+
--locales th --apply --limit 20
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Env var | Default | Purpose |
|
|
60
|
+
| ------- | ------- | ------- |
|
|
61
|
+
| `TRANSLATE_PROVIDER` | — | Enables `--apply` (e.g. `openai`) |
|
|
62
|
+
| `TRANSLATE_API_KEY` | `OPENAI_API_KEY` | Provider API key |
|
|
63
|
+
| `TRANSLATE_BASE_URL` | `https://api.openai.com/v1` | OpenAI-compatible endpoint |
|
|
64
|
+
| `TRANSLATE_MODEL` | `gpt-4o-mini` | Chat model |
|
|
65
|
+
| `TRANSLATE_CONCURRENCY` | `3` | Parallel requests |
|
|
66
|
+
| `SOURCE_LOCALE` | `en` | Source language |
|
|
43
67
|
|
|
44
68
|
The `/translate` page derives per-doc status from doc ids and tags: a doc
|
|
45
69
|
counts as translated for a locale when its id contains `.{locale}` or
|
|
46
|
-
`--{locale}
|
|
70
|
+
`--{locale}` (e.g. `th--index`), or when it carries a `lang:{locale}` /
|
|
71
|
+
`locale:{locale}` tag.
|
package/package.json
CHANGED
package/scripts/translate.ts
CHANGED
|
@@ -1,22 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* create-docs translate — CI-friendly AI translation
|
|
3
|
+
* create-docs translate — CI-friendly AI translation pipeline.
|
|
4
4
|
*
|
|
5
5
|
* Scans a docs directory, computes which (document, locale) pairs are missing
|
|
6
|
-
* translations, and writes a `translation-plan.json`
|
|
7
|
-
*
|
|
6
|
+
* translations, and writes a `translation-plan.json`. With `--apply` and a
|
|
7
|
+
* configured provider, it translates the pending tasks through an
|
|
8
|
+
* OpenAI-compatible chat-completions endpoint.
|
|
8
9
|
*
|
|
9
10
|
* Usage:
|
|
10
11
|
* bun scripts/translate.ts --docs docs --locales th,ja [--out translation-plan.json]
|
|
12
|
+
* bun scripts/translate.ts --docs docs/docs/bun-packages --i18n i18n/bun-packages --locales th --apply [--limit 20]
|
|
11
13
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* (e.g.
|
|
14
|
+
* --i18n <dir> Write translations to <dir>/<locale>/<file> instead of
|
|
15
|
+
* <docs>/<locale>/<file>. Use this when the docs directory is
|
|
16
|
+
* generated (e.g. pulled from GitHub and gitignored) so the
|
|
17
|
+
* translations live in a tracked folder that survives re-pulls.
|
|
18
|
+
*
|
|
19
|
+
* Environment:
|
|
20
|
+
* TRANSLATE_PROVIDER e.g. "openai" (any OpenAI-compatible endpoint works)
|
|
21
|
+
* TRANSLATE_API_KEY API key (falls back to OPENAI_API_KEY)
|
|
22
|
+
* TRANSLATE_BASE_URL default https://api.openai.com/v1
|
|
23
|
+
* TRANSLATE_MODEL default gpt-4o-mini
|
|
24
|
+
* TRANSLATE_CONCURRENCY default 3
|
|
25
|
+
* SOURCE_LOCALE default en
|
|
15
26
|
*/
|
|
16
27
|
|
|
17
28
|
import {
|
|
18
29
|
mkdirSync,
|
|
19
30
|
readdirSync,
|
|
31
|
+
readFileSync,
|
|
20
32
|
statSync,
|
|
21
33
|
writeFileSync,
|
|
22
34
|
} from "node:fs";
|
|
@@ -24,19 +36,28 @@ import * as path from "node:path";
|
|
|
24
36
|
|
|
25
37
|
interface CliArgs {
|
|
26
38
|
docs: string;
|
|
39
|
+
i18n: string;
|
|
27
40
|
locales: string[];
|
|
28
41
|
out: string;
|
|
42
|
+
apply: boolean;
|
|
43
|
+
limit: number;
|
|
29
44
|
}
|
|
30
45
|
|
|
31
46
|
function parseArgs(argv: string[]): CliArgs {
|
|
32
47
|
const args: CliArgs = {
|
|
33
48
|
docs: "docs",
|
|
49
|
+
i18n: "",
|
|
34
50
|
locales: [],
|
|
35
51
|
out: "translation-plan.json",
|
|
52
|
+
apply: false,
|
|
53
|
+
limit: Number.POSITIVE_INFINITY,
|
|
36
54
|
};
|
|
37
55
|
for (let i = 0; i < argv.length; i++) {
|
|
38
56
|
const a = argv[i];
|
|
39
57
|
if (a === "--docs") args.docs = argv[++i] ?? args.docs;
|
|
58
|
+
else if (a === "--i18n") args.i18n = argv[++i] ?? "";
|
|
59
|
+
else if (a === "--apply") args.apply = true;
|
|
60
|
+
else if (a === "--limit") args.limit = Number(argv[++i] ?? "0") || args.limit;
|
|
40
61
|
else if (a === "--locales")
|
|
41
62
|
args.locales = (argv[++i] ?? "")
|
|
42
63
|
.split(",")
|
|
@@ -63,7 +84,87 @@ function walk(dir: string, prefix = ""): string[] {
|
|
|
63
84
|
return out;
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
interface TranslateTask {
|
|
88
|
+
source: string;
|
|
89
|
+
target: string;
|
|
90
|
+
locale: string;
|
|
91
|
+
sourcePath: string;
|
|
92
|
+
targetPath: string;
|
|
93
|
+
status: "pending" | "translated" | "failed";
|
|
94
|
+
error?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const SYSTEM_PROMPT = `You are a technical documentation translator.
|
|
98
|
+
Translate the markdown document the user sends into the target language.
|
|
99
|
+
|
|
100
|
+
Rules:
|
|
101
|
+
- Keep YAML frontmatter keys unchanged; translate only human-readable string
|
|
102
|
+
values (title, description, section, etc.). Keep the keys themselves in English.
|
|
103
|
+
- Do not translate: code blocks, inline code, URLs, import paths, identifiers,
|
|
104
|
+
component names, or frontmatter keys.
|
|
105
|
+
- Preserve all markdown structure, heading levels, links, images, and
|
|
106
|
+
frontmatter delimiters exactly.
|
|
107
|
+
- Output ONLY the translated document. No commentary, no wrapping fences.`;
|
|
108
|
+
|
|
109
|
+
async function translateFile(
|
|
110
|
+
task: TranslateTask,
|
|
111
|
+
localeName: string,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
const baseUrl = (
|
|
114
|
+
process.env.TRANSLATE_BASE_URL ?? "https://api.openai.com/v1"
|
|
115
|
+
).replace(/\/$/, "");
|
|
116
|
+
const apiKey = process.env.TRANSLATE_API_KEY ?? process.env.OPENAI_API_KEY;
|
|
117
|
+
const model = process.env.TRANSLATE_MODEL ?? "gpt-4o-mini";
|
|
118
|
+
if (!apiKey) throw new Error("TRANSLATE_API_KEY / OPENAI_API_KEY not set");
|
|
119
|
+
|
|
120
|
+
const content = readFileSync(task.sourcePath, "utf8");
|
|
121
|
+
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: {
|
|
124
|
+
"Content-Type": "application/json",
|
|
125
|
+
Authorization: `Bearer ${apiKey}`,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
model,
|
|
129
|
+
temperature: 0.2,
|
|
130
|
+
messages: [
|
|
131
|
+
{ role: "system", content: SYSTEM_PROMPT },
|
|
132
|
+
{
|
|
133
|
+
role: "user",
|
|
134
|
+
content: `Target language: ${localeName}\n\n${content}`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
throw new Error(`Provider ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
|
141
|
+
}
|
|
142
|
+
const json = (await res.json()) as {
|
|
143
|
+
choices?: { message?: { content?: string } }[];
|
|
144
|
+
};
|
|
145
|
+
const translated = json.choices?.[0]?.message?.content;
|
|
146
|
+
if (!translated) throw new Error("Empty translation response");
|
|
147
|
+
|
|
148
|
+
mkdirSync(path.dirname(task.targetPath), { recursive: true });
|
|
149
|
+
writeFileSync(task.targetPath, translated, "utf8");
|
|
150
|
+
task.status = "translated";
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const LOCALE_NAMES: Record<string, string> = {
|
|
154
|
+
th: "Thai",
|
|
155
|
+
ja: "Japanese",
|
|
156
|
+
zh: "Simplified Chinese",
|
|
157
|
+
ko: "Korean",
|
|
158
|
+
vi: "Vietnamese",
|
|
159
|
+
id: "Indonesian",
|
|
160
|
+
fr: "French",
|
|
161
|
+
de: "German",
|
|
162
|
+
es: "Spanish",
|
|
163
|
+
pt: "Portuguese",
|
|
164
|
+
hi: "Hindi",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
67
168
|
const args = parseArgs(process.argv.slice(2));
|
|
68
169
|
const docsDir = path.resolve(args.docs);
|
|
69
170
|
if (!statSync(docsDir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
@@ -71,30 +172,28 @@ function main() {
|
|
|
71
172
|
process.exit(1);
|
|
72
173
|
}
|
|
73
174
|
|
|
74
|
-
const files = walk(docsDir)
|
|
75
|
-
|
|
175
|
+
const files = walk(docsDir).filter(
|
|
176
|
+
// Never treat already-translated files (<dir>/<locale>/...) as sources.
|
|
177
|
+
(f) => !args.locales.includes(f.split("/")[0] ?? ""),
|
|
178
|
+
);
|
|
179
|
+
const provider = process.env.TRANSLATE_PROVIDER;
|
|
180
|
+
// Translations go to --i18n/<locale>/... when set, else <docs>/<locale>/...
|
|
181
|
+
const targetRoot = args.i18n ? path.resolve(args.i18n) : docsDir;
|
|
76
182
|
|
|
77
183
|
const plan = {
|
|
78
184
|
generatedAt: new Date().toISOString(),
|
|
79
|
-
provider,
|
|
185
|
+
provider: provider ?? "none",
|
|
80
186
|
sourceLocale: process.env.SOURCE_LOCALE ?? "en",
|
|
81
187
|
targetLocales: args.locales,
|
|
82
188
|
docsDir: args.docs,
|
|
83
|
-
tasks: [] as
|
|
84
|
-
source: string;
|
|
85
|
-
target: string;
|
|
86
|
-
locale: string;
|
|
87
|
-
sourcePath: string;
|
|
88
|
-
targetPath: string;
|
|
89
|
-
status: "pending" | "translated";
|
|
90
|
-
}>,
|
|
189
|
+
tasks: [] as TranslateTask[],
|
|
91
190
|
};
|
|
92
191
|
|
|
93
192
|
let pending = 0;
|
|
94
193
|
let done = 0;
|
|
95
194
|
for (const file of files) {
|
|
96
195
|
for (const locale of args.locales) {
|
|
97
|
-
const targetPath = path.join(
|
|
196
|
+
const targetPath = path.join(targetRoot, locale, file);
|
|
98
197
|
const exists =
|
|
99
198
|
statSync(targetPath, { throwIfNoEntry: false })?.isFile() ?? false;
|
|
100
199
|
plan.tasks.push({
|
|
@@ -110,18 +209,55 @@ function main() {
|
|
|
110
209
|
}
|
|
111
210
|
}
|
|
112
211
|
|
|
212
|
+
let applied = 0;
|
|
213
|
+
let failed = 0;
|
|
214
|
+
if (args.apply && provider) {
|
|
215
|
+
const queue = plan.tasks
|
|
216
|
+
.filter((t) => t.status === "pending")
|
|
217
|
+
.slice(0, args.limit);
|
|
218
|
+
const concurrency = Math.max(
|
|
219
|
+
1,
|
|
220
|
+
Number(process.env.TRANSLATE_CONCURRENCY ?? "3") || 3,
|
|
221
|
+
);
|
|
222
|
+
console.log(
|
|
223
|
+
`[create-docs translate] applying ${queue.length} task(s) via ${provider} (concurrency ${concurrency})`,
|
|
224
|
+
);
|
|
225
|
+
let cursor = 0;
|
|
226
|
+
const worker = async () => {
|
|
227
|
+
while (cursor < queue.length) {
|
|
228
|
+
const task = queue[cursor++];
|
|
229
|
+
try {
|
|
230
|
+
await translateFile(task, LOCALE_NAMES[task.locale] ?? task.locale);
|
|
231
|
+
applied++;
|
|
232
|
+
console.log(` translated ${task.target}`);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
failed++;
|
|
235
|
+
task.status = "failed";
|
|
236
|
+
task.error = err instanceof Error ? err.message : String(err);
|
|
237
|
+
console.error(` FAILED ${task.target}: ${task.error}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
await Promise.all(
|
|
242
|
+
Array.from({ length: Math.min(concurrency, queue.length) }, worker),
|
|
243
|
+
);
|
|
244
|
+
pending = plan.tasks.filter((t) => t.status === "pending").length;
|
|
245
|
+
done = plan.tasks.filter((t) => t.status === "translated").length;
|
|
246
|
+
}
|
|
247
|
+
|
|
113
248
|
mkdirSync(path.dirname(path.resolve(args.out)), { recursive: true });
|
|
114
249
|
writeFileSync(args.out, JSON.stringify(plan, null, 2), "utf8");
|
|
115
250
|
|
|
116
251
|
console.log(
|
|
117
|
-
`[create-docs translate] ${files.length} docs x ${args.locales.length} locales -> ${done} translated, ${pending} pending`,
|
|
252
|
+
`[create-docs translate] ${files.length} docs x ${args.locales.length} locales -> ${done} translated, ${pending} pending${applied ? `, ${applied} applied` : ""}${failed ? `, ${failed} failed` : ""}`,
|
|
118
253
|
);
|
|
119
254
|
console.log(`Plan written to ${path.resolve(args.out)}`);
|
|
120
|
-
if (pending > 0 && !
|
|
255
|
+
if (pending > 0 && !provider) {
|
|
121
256
|
console.log(
|
|
122
|
-
"No TRANSLATE_PROVIDER configured — plan emitted for review; set a provider +
|
|
257
|
+
"No TRANSLATE_PROVIDER configured — plan emitted for review; set a provider + TRANSLATE_API_KEY and pass --apply to translate.",
|
|
123
258
|
);
|
|
124
259
|
}
|
|
260
|
+
if (failed > 0) process.exit(1);
|
|
125
261
|
}
|
|
126
262
|
|
|
127
|
-
main();
|
|
263
|
+
await main();
|