@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 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 docs --locales th,ja` scans your 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. A CI workflow (e.g. `.github/workflows/translate.yml`) runs the planner
39
- and once a `TRANSLATE_PROVIDER` and API key are configured — feeds the
40
- plan to the provider.
41
- 3. Translated markdown lands under `docs/<locale>/…`, gets committed, and the
42
- locale switcher picks it up automatically.
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}`, or when it carries a `lang:{locale}` / `locale:{locale}` tag.
70
+ `--{locale}` (e.g. `th--index`), or when it carries a `lang:{locale}` /
71
+ `locale:{locale}` tag.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@wrikka/create-docs",
3
3
  "description": "Vite plugin for documentation sites built on Functional Clean Architecture with SolidJS support",
4
- "version": "0.2.1",
4
+ "version": "0.2.2",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "create-docs": "bin/cli.js",
@@ -1,22 +1,34 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
- * create-docs translate — CI-friendly AI translation planner.
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` that a CI workflow (or an
7
- * AI provider integration) can consume.
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
- * The script is intentionally a planner: it does not call an AI provider yet.
13
- * To wire a real provider, set TRANSLATE_PROVIDER plus the provider's API key
14
- * (e.g. OPENAI_API_KEY) and pipe the emitted plan into your translation step.
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
- function main() {
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
- const provider = process.env.TRANSLATE_PROVIDER ?? "ai";
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 Array<{
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(docsDir, locale, file);
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 && !process.env.TRANSLATE_PROVIDER) {
255
+ if (pending > 0 && !provider) {
121
256
  console.log(
122
- "No TRANSLATE_PROVIDER configured — plan emitted for review; set a provider + API key to run real translations.",
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();