docs-i18n 0.1.0

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.
@@ -0,0 +1,396 @@
1
+ // src/core/cache.ts
2
+ import fs from "fs";
3
+ import path from "path";
4
+
5
+ // src/core/sqlite.ts
6
+ import { createRequire } from "module";
7
+ var isBun = typeof globalThis.Bun !== "undefined";
8
+ function openDatabase(filepath) {
9
+ if (isBun) {
10
+ const req2 = createRequire(import.meta.url);
11
+ const mod = req2("bun:sqlite");
12
+ const Database2 = mod.Database ?? mod.default ?? mod;
13
+ const raw = new Database2(filepath);
14
+ return {
15
+ prepare: (sql) => {
16
+ const stmt = raw.query(sql);
17
+ return {
18
+ run: (...params) => stmt.run(...params),
19
+ get: (...params) => stmt.get(...params),
20
+ all: (...params) => stmt.all(...params)
21
+ };
22
+ },
23
+ exec: (sql) => raw.run(sql),
24
+ pragma: (p) => raw.run(`PRAGMA ${p}`),
25
+ transaction: (fn) => raw.transaction(fn),
26
+ close: () => raw.close()
27
+ };
28
+ }
29
+ const req = createRequire(import.meta.url);
30
+ const Database = req("better-sqlite3");
31
+ return new Database(filepath);
32
+ }
33
+
34
+ // src/core/cache.ts
35
+ var TranslationCache = class {
36
+ db;
37
+ cacheDir;
38
+ // Prepared statements (lazily initialized)
39
+ _stmts;
40
+ constructor(cacheDir) {
41
+ this.cacheDir = cacheDir;
42
+ fs.mkdirSync(cacheDir, { recursive: true });
43
+ const dbPath = path.join(cacheDir, "translations.db");
44
+ this.db = openDatabase(dbPath);
45
+ this.db.pragma("journal_mode = WAL");
46
+ this.db.pragma("busy_timeout = 5000");
47
+ this.db.pragma("synchronous = NORMAL");
48
+ this.db.pragma("foreign_keys = ON");
49
+ this.db.exec(`
50
+ CREATE TABLE IF NOT EXISTS sources (
51
+ key TEXT PRIMARY KEY NOT NULL,
52
+ text TEXT NOT NULL,
53
+ type TEXT NOT NULL DEFAULT 'paragraph'
54
+ ) WITHOUT ROWID;
55
+
56
+ CREATE TABLE IF NOT EXISTS source_files (
57
+ key TEXT NOT NULL,
58
+ file TEXT NOT NULL,
59
+ line INTEGER NOT NULL,
60
+ version TEXT NOT NULL DEFAULT 'latest',
61
+ PRIMARY KEY (version, key, file, line)
62
+ ) WITHOUT ROWID;
63
+
64
+ CREATE TABLE IF NOT EXISTS translations (
65
+ lang TEXT NOT NULL,
66
+ key TEXT NOT NULL,
67
+ value TEXT NOT NULL,
68
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
69
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
70
+ PRIMARY KEY (lang, key)
71
+ ) WITHOUT ROWID;
72
+
73
+ CREATE INDEX IF NOT EXISTS idx_translations_lang ON translations(lang);
74
+ CREATE INDEX IF NOT EXISTS idx_source_files_version ON source_files(version);
75
+ CREATE INDEX IF NOT EXISTS idx_source_files_file ON source_files(version, file);
76
+ `);
77
+ }
78
+ get stmts() {
79
+ if (!this._stmts) {
80
+ this._stmts = {
81
+ getTranslation: this.db.prepare(
82
+ "SELECT value FROM translations WHERE lang = ? AND key = ?"
83
+ ),
84
+ setTranslation: this.db.prepare(`
85
+ INSERT INTO translations (lang, key, value)
86
+ VALUES (?, ?, ?)
87
+ ON CONFLICT(lang, key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
88
+ `),
89
+ deleteTranslation: this.db.prepare(
90
+ "DELETE FROM translations WHERE lang = ? AND key = ?"
91
+ ),
92
+ countByLang: this.db.prepare(
93
+ "SELECT COUNT(*) as count FROM translations WHERE lang = ?"
94
+ ),
95
+ allByLang: this.db.prepare(
96
+ "SELECT key, value FROM translations WHERE lang = ?"
97
+ ),
98
+ keysByLang: this.db.prepare(
99
+ "SELECT key FROM translations WHERE lang = ?"
100
+ ),
101
+ setSource: this.db.prepare(`
102
+ INSERT INTO sources (key, text, type) VALUES (?, ?, ?)
103
+ ON CONFLICT(key) DO UPDATE SET text = excluded.text, type = excluded.type
104
+ `),
105
+ getSource: this.db.prepare(
106
+ "SELECT text, type FROM sources WHERE key = ?"
107
+ ),
108
+ clearSourceFiles: this.db.prepare(
109
+ "DELETE FROM source_files WHERE version = ?"
110
+ ),
111
+ upsertSourceFile: this.db.prepare(`
112
+ INSERT OR IGNORE INTO source_files (key, file, line, version) VALUES (?, ?, ?, ?)
113
+ `),
114
+ deleteSourceFilesByLang: this.db.prepare(
115
+ "DELETE FROM source_files WHERE version = ?"
116
+ )
117
+ };
118
+ }
119
+ return this._stmts;
120
+ }
121
+ // ── Public API (backward-compatible with JSONL version) ──
122
+ /**
123
+ * Load cache for a language. No-op for SQLite (data is always available).
124
+ * Kept for API compatibility.
125
+ */
126
+ load(_lang) {
127
+ }
128
+ /**
129
+ * Save cache for a language. No-op for SQLite (writes are immediate).
130
+ * Kept for API compatibility.
131
+ */
132
+ save(_lang) {
133
+ }
134
+ /** Get a cached translation */
135
+ get(lang, md5) {
136
+ const row = this.stmts.getTranslation.get(lang, md5);
137
+ return row?.value;
138
+ }
139
+ /** Set a cached translation (writes immediately to disk) */
140
+ set(lang, md5, translation) {
141
+ this.stmts.setTranslation.run(lang, md5, translation);
142
+ }
143
+ /**
144
+ * Set a cached translation with its EN source text.
145
+ * This also stores the source in the `sources` table for dashboard use.
146
+ */
147
+ setWithSource(lang, md5, translation, sourceText, sourceType = "paragraph") {
148
+ this.db.transaction(() => {
149
+ this.stmts.setSource.run(md5, sourceText, sourceType);
150
+ this.stmts.setTranslation.run(lang, md5, translation);
151
+ })();
152
+ }
153
+ /** Delete a cached translation */
154
+ delete(lang, md5) {
155
+ const result = this.stmts.deleteTranslation.run(lang, md5);
156
+ return result.changes > 0;
157
+ }
158
+ /** Iterate all entries for a language */
159
+ *entries(lang) {
160
+ const rows = this.stmts.allByLang.all(lang);
161
+ for (const row of rows) {
162
+ yield [row.key, { v: row.value, src: [] }];
163
+ }
164
+ }
165
+ /** Get all cached keys for a language as a Set (fast for coverage checks) */
166
+ keys(lang) {
167
+ const rows = this.stmts.keysByLang.all(lang);
168
+ return new Set(rows.map((r) => r.key));
169
+ }
170
+ /** Remove entries not in the provided set of md5s */
171
+ prune(lang, usedMd5s) {
172
+ const allKeys = this.keys(lang);
173
+ let removed = 0;
174
+ const deleteStmt = this.stmts.deleteTranslation;
175
+ this.db.transaction(() => {
176
+ for (const md5 of allKeys) {
177
+ if (!usedMd5s.has(md5)) {
178
+ deleteStmt.run(lang, md5);
179
+ removed++;
180
+ }
181
+ }
182
+ })();
183
+ return removed;
184
+ }
185
+ /** Get cache stats for a language */
186
+ stats(lang) {
187
+ const row = this.stmts.countByLang.get(lang);
188
+ return { size: row?.count ?? 0 };
189
+ }
190
+ // ── Source tracking ──
191
+ /**
192
+ * Update source locations for a md5 hash.
193
+ * In SQLite mode, this inserts into source_files table.
194
+ */
195
+ updateSource(_lang, md5, filePath, line, version = "latest") {
196
+ this.stmts.upsertSourceFile.run(md5, filePath, line, version);
197
+ }
198
+ /** Clear all source file mappings for a version (call before a full rebuild) */
199
+ clearSources(_lang, version = "latest") {
200
+ this.stmts.clearSourceFiles.run(version);
201
+ }
202
+ /** Store an EN source text in the sources table */
203
+ setSource(md5, text, type = "paragraph") {
204
+ this.stmts.setSource.run(md5, text, type);
205
+ }
206
+ /** Get an EN source text from the sources table */
207
+ getSource(md5) {
208
+ const row = this.stmts.getSource.get(md5);
209
+ return row ?? void 0;
210
+ }
211
+ /**
212
+ * Get all untranslated source keys for a language and version.
213
+ * Returns keys with their EN source text and type.
214
+ */
215
+ untranslatedKeys(lang, version = "latest", limit = 0, files) {
216
+ let fileFilter = "";
217
+ const params = [lang, version];
218
+ if (files && files.length > 0) {
219
+ fileFilter = ` AND sf.file IN (${files.map(() => "?").join(",")})`;
220
+ params.push(...files);
221
+ }
222
+ const sql = `
223
+ SELECT DISTINCT s.key, s.text, s.type
224
+ FROM source_files sf
225
+ JOIN sources s ON s.key = sf.key
226
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
227
+ WHERE sf.version = ? AND t.key IS NULL${fileFilter}
228
+ ${limit > 0 ? `LIMIT ${limit}` : ""}
229
+ `;
230
+ return this.db.prepare(sql).all(...params);
231
+ }
232
+ // ── Dashboard queries ──
233
+ /** Get translation stats for all languages */
234
+ allLangStats() {
235
+ return this.db.prepare("SELECT lang, COUNT(*) as count FROM translations GROUP BY lang").all();
236
+ }
237
+ /**
238
+ * Get file-level coverage for a version and language.
239
+ * Returns how many source nodes each file has and how many are translated.
240
+ */
241
+ fileCoverage(version, lang) {
242
+ return this.db.prepare(
243
+ `
244
+ SELECT
245
+ sf.file,
246
+ COUNT(DISTINCT sf.key) as total,
247
+ COUNT(DISTINCT t.key) as translated
248
+ FROM source_files sf
249
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
250
+ WHERE sf.version = ?
251
+ GROUP BY sf.file
252
+ ORDER BY sf.file
253
+ `
254
+ ).all(lang, version);
255
+ }
256
+ /**
257
+ * Get node-level detail for a specific file: EN source + translation side by side.
258
+ */
259
+ fileDetail(version, lang, file) {
260
+ return this.db.prepare(
261
+ `
262
+ SELECT
263
+ s.key,
264
+ s.text as source,
265
+ s.type,
266
+ t.value as translation,
267
+ sf.line
268
+ FROM source_files sf
269
+ JOIN sources s ON s.key = sf.key
270
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
271
+ WHERE sf.version = ? AND sf.file = ?
272
+ ORDER BY sf.line
273
+ `
274
+ ).all(lang, version, file);
275
+ }
276
+ /** Get total source node count for a version */
277
+ sourceCount(version) {
278
+ const row = this.db.prepare(
279
+ "SELECT COUNT(DISTINCT key) as count FROM source_files WHERE version = ?"
280
+ ).get(version);
281
+ return row?.count ?? 0;
282
+ }
283
+ /**
284
+ * Get section-level stats for a version and language.
285
+ * Section is derived from file path prefix (docs/, blog/, learn/).
286
+ */
287
+ sectionStats(version, lang) {
288
+ return this.db.prepare(
289
+ `
290
+ WITH file_coverage AS (
291
+ SELECT
292
+ sf.file,
293
+ CASE
294
+ WHEN sf.file LIKE 'docs/%' THEN 'docs'
295
+ WHEN sf.file LIKE 'blog/%' THEN 'blog'
296
+ WHEN sf.file LIKE 'learn/%' THEN 'learn'
297
+ ELSE 'other'
298
+ END as section,
299
+ COUNT(DISTINCT sf.key) as total_nodes,
300
+ COUNT(DISTINCT t.key) as translated_nodes
301
+ FROM source_files sf
302
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
303
+ WHERE sf.version = ?
304
+ GROUP BY sf.file
305
+ )
306
+ SELECT
307
+ section,
308
+ COUNT(*) as totalFiles,
309
+ SUM(CASE WHEN translated_nodes = total_nodes THEN 1 ELSE 0 END) as translatedFiles,
310
+ SUM(total_nodes) as totalNodes,
311
+ SUM(translated_nodes) as translatedNodes
312
+ FROM file_coverage
313
+ GROUP BY section
314
+ ORDER BY section
315
+ `
316
+ ).all(lang, version);
317
+ }
318
+ // ── Export / Import ──
319
+ /** Export cache to JSONL format (for backward compatibility / git tracking) */
320
+ exportJsonl(lang, outputPath) {
321
+ const rows = this.db.prepare(
322
+ "SELECT key, value FROM translations WHERE lang = ? ORDER BY key"
323
+ ).all(lang);
324
+ const lines = [];
325
+ for (const row of rows) {
326
+ const srcs = this.db.prepare(
327
+ "SELECT file || ':' || line as loc FROM source_files WHERE key = ? ORDER BY file, line"
328
+ ).all(row.key);
329
+ const entry = { k: row.key, v: row.value };
330
+ if (srcs.length > 0) {
331
+ entry.src = srcs.map((s) => s.loc);
332
+ }
333
+ lines.push(JSON.stringify(entry));
334
+ }
335
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
336
+ fs.writeFileSync(outputPath, `${lines.join("\n")}
337
+ `, "utf8");
338
+ return rows.length;
339
+ }
340
+ /** Import translations from a JSONL file */
341
+ importJsonl(lang, inputPath) {
342
+ if (!fs.existsSync(inputPath)) return 0;
343
+ const lines = fs.readFileSync(inputPath, "utf8").split("\n");
344
+ let count = 0;
345
+ this.db.transaction(() => {
346
+ for (const line of lines) {
347
+ const trimmed = line.trim();
348
+ if (!trimmed) continue;
349
+ const entry = JSON.parse(trimmed);
350
+ this.stmts.setTranslation.run(lang, entry.k, entry.v);
351
+ count++;
352
+ }
353
+ })();
354
+ return count;
355
+ }
356
+ /** Export a readable markdown index for IDE navigation */
357
+ exportIndex(lang) {
358
+ const indexPath = path.join(this.cacheDir, `${lang}.index.md`);
359
+ const rows = this.db.prepare(
360
+ `
361
+ SELECT t.key, t.value,
362
+ GROUP_CONCAT(sf.file || ':' || sf.line, char(10)) as sources
363
+ FROM translations t
364
+ LEFT JOIN source_files sf ON sf.key = t.key
365
+ WHERE t.lang = ?
366
+ GROUP BY t.key
367
+ HAVING sources IS NOT NULL
368
+ ORDER BY t.key
369
+ `
370
+ ).all(lang);
371
+ const lines = [`# Translation Index (${lang})`, ""];
372
+ for (const row of rows) {
373
+ if (!row.sources) continue;
374
+ const preview = row.value.split("\n")[0].substring(0, 80);
375
+ lines.push(`## \`${row.key}\` ${preview}`);
376
+ lines.push("");
377
+ for (const src of row.sources.split("\n")) {
378
+ const [filePath] = src.split(":");
379
+ lines.push(
380
+ `- [${src}](../content/en/${filePath}#L${src.split(":")[1]})`
381
+ );
382
+ }
383
+ lines.push("");
384
+ }
385
+ fs.writeFileSync(indexPath, lines.join("\n"), "utf8");
386
+ }
387
+ /** Close the database connection */
388
+ close() {
389
+ this._stmts = void 0;
390
+ this.db.close();
391
+ }
392
+ };
393
+
394
+ export {
395
+ TranslationCache
396
+ };
package/dist/cli.js ADDED
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ loadConfig
4
+ } from "./chunk-3YNFMSJH.js";
5
+ import "./chunk-AKLW2MUS.js";
6
+
7
+ // src/cli.ts
8
+ var args = process.argv.slice(2);
9
+ var command = args[0];
10
+ function getOpt(name, def = "") {
11
+ const idx = args.indexOf(`--${name}`);
12
+ return idx >= 0 && idx + 1 < args.length ? args[idx + 1] : def;
13
+ }
14
+ function hasFlag(name) {
15
+ return args.includes(`--${name}`);
16
+ }
17
+ async function main() {
18
+ if (!command || command === "--help" || command === "-h") {
19
+ console.log(`
20
+ docs-i18n \u2014 Universal documentation translation engine
21
+
22
+ Commands:
23
+ translate Translate content to target language(s)
24
+ assemble Assemble translated files from EN + cache
25
+ rescan Rescan source files and clean orphans
26
+ status Show translation coverage
27
+ admin Start admin dashboard
28
+
29
+ Options:
30
+ --lang <code> Target language (e.g., zh-hans)
31
+ --project <id> Filter by project
32
+ --version <ver> Filter by version
33
+ --config <path> Config file (default: docs-i18n.config.ts)
34
+ --max <n> Max API calls
35
+ --concurrency <n> Parallel API calls (default: 3)
36
+ --model <model> Override LLM model
37
+ --max-tokens <n> Max output tokens (default: 16384)
38
+ --context-length <n> Model context window (default: 32768)
39
+ --dry-run Preview without making changes
40
+ --port <n> Admin dashboard port (default: 3456)
41
+ `);
42
+ return;
43
+ }
44
+ const configPath = getOpt("config") || void 0;
45
+ const config = await loadConfig(configPath);
46
+ switch (command) {
47
+ case "translate": {
48
+ const lang = getOpt("lang");
49
+ if (!lang) {
50
+ console.error("Error: --lang is required");
51
+ process.exit(1);
52
+ }
53
+ const { translate } = await import("./translate-F3AQFN6X.js");
54
+ await translate(config, {
55
+ lang,
56
+ project: getOpt("project") || void 0,
57
+ version: getOpt("version") || void 0,
58
+ files: getOpt("files") ? getOpt("files").split(",") : void 0,
59
+ max: getOpt("max") ? Number(getOpt("max")) : void 0,
60
+ concurrency: getOpt("concurrency") ? Number(getOpt("concurrency")) : void 0,
61
+ dryRun: hasFlag("dry-run"),
62
+ model: getOpt("model") || void 0,
63
+ maxTokens: getOpt("max-tokens") ? Number(getOpt("max-tokens")) : void 0,
64
+ contextLength: getOpt("context-length") ? Number(getOpt("context-length")) : void 0
65
+ });
66
+ break;
67
+ }
68
+ case "assemble": {
69
+ const { assembleAll } = await import("./assemble-IOHQYYHI.js");
70
+ await assembleAll(config, {
71
+ project: getOpt("project") || void 0,
72
+ version: getOpt("version") || void 0,
73
+ lang: getOpt("lang") || void 0
74
+ });
75
+ break;
76
+ }
77
+ case "rescan": {
78
+ const { rescan } = await import("./rescan-VB2PILB2.js");
79
+ await rescan(config, {
80
+ project: getOpt("project") || void 0,
81
+ version: getOpt("version") || void 0
82
+ });
83
+ break;
84
+ }
85
+ case "status": {
86
+ const { status } = await import("./status-EWQEACVF.js");
87
+ await status(config, { lang: getOpt("lang") || void 0 });
88
+ break;
89
+ }
90
+ case "admin": {
91
+ const port = Number(getOpt("port", "3456"));
92
+ const { startAdmin } = await import("./server-ER56DGPR.js");
93
+ await startAdmin(config, port);
94
+ break;
95
+ }
96
+ default:
97
+ console.error(`Unknown command: ${command}`);
98
+ process.exit(1);
99
+ }
100
+ }
101
+ main().catch((err) => {
102
+ console.error("\u274C", err.message ?? err);
103
+ process.exit(1);
104
+ });
@@ -0,0 +1,19 @@
1
+ import {
2
+ RUNTIME_MODULE_ID,
3
+ RolldownMagicString,
4
+ VERSION,
5
+ build,
6
+ defineConfig,
7
+ rolldown,
8
+ watch
9
+ } from "./chunk-VKKNQBDN.js";
10
+ import "./chunk-AKLW2MUS.js";
11
+ export {
12
+ RUNTIME_MODULE_ID,
13
+ RolldownMagicString,
14
+ VERSION,
15
+ build,
16
+ defineConfig,
17
+ rolldown,
18
+ watch
19
+ };