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.
package/dist/index.js ADDED
@@ -0,0 +1,545 @@
1
+ import {
2
+ FRONTMATTER_TRANSLATABLE_FIELDS,
3
+ normalize,
4
+ parseMdx
5
+ } from "./chunk-7HWWVRNS.js";
6
+
7
+ // src/config.ts
8
+ function defineConfig(config) {
9
+ return config;
10
+ }
11
+ async function loadConfig(path2) {
12
+ const configPath = path2 ?? "docs-i18n.config.ts";
13
+ try {
14
+ const mod = await import(
15
+ /* @vite-ignore */
16
+ `${process.cwd()}/${configPath}`
17
+ );
18
+ return mod.default ?? mod;
19
+ } catch {
20
+ throw new Error(`Cannot load config from ${configPath}. Create a docs-i18n.config.ts file.`);
21
+ }
22
+ }
23
+ function flattenSources(config) {
24
+ const projects = Object.entries(config.projects);
25
+ const singleProject = projects.length === 1;
26
+ return projects.flatMap(
27
+ ([projectId, project]) => Object.entries(project.sources).map(([version, sourcePath]) => ({
28
+ project: projectId,
29
+ version,
30
+ sourcePath,
31
+ versionKey: singleProject ? version : `${projectId}/${version}`
32
+ }))
33
+ );
34
+ }
35
+
36
+ // src/core/assembler.ts
37
+ var NEEDS_TRANSLATION_START = "<!-- NEEDS_TRANSLATION -->";
38
+ var NEEDS_TRANSLATION_END = "<!-- /NEEDS_TRANSLATION -->";
39
+ function assemble(rawContent, lang, cache, sourceFilePath, fallbackToSource = false) {
40
+ const normalizedContent = normalize(rawContent);
41
+ const nodes = parseMdx(rawContent);
42
+ let result = "";
43
+ let lastEnd = 0;
44
+ let cachedCount = 0;
45
+ let uncachedCount = 0;
46
+ let totalTranslatable = 0;
47
+ for (const node of nodes) {
48
+ result += normalizedContent.substring(lastEnd, node.startOffset);
49
+ if (node.needsTranslation) {
50
+ totalTranslatable++;
51
+ const cached = node.md5 ? cache.get(lang, node.md5) : void 0;
52
+ if (node.md5 && sourceFilePath) {
53
+ const line = normalizedContent.substring(0, node.startOffset).split("\n").length;
54
+ cache.updateSource(lang, node.md5, sourceFilePath, line);
55
+ }
56
+ if (cached) {
57
+ result += cached;
58
+ cachedCount++;
59
+ } else if (fallbackToSource) {
60
+ result += node.rawText;
61
+ uncachedCount++;
62
+ } else {
63
+ result += `${NEEDS_TRANSLATION_START}
64
+ ${node.rawText}
65
+ ${NEEDS_TRANSLATION_END}`;
66
+ uncachedCount++;
67
+ }
68
+ } else {
69
+ result += node.rawText;
70
+ }
71
+ lastEnd = node.endOffset;
72
+ }
73
+ result += normalizedContent.substring(lastEnd);
74
+ return {
75
+ content: result,
76
+ allCached: uncachedCount === 0,
77
+ cachedCount,
78
+ uncachedCount,
79
+ totalTranslatable,
80
+ parsedNodes: nodes
81
+ };
82
+ }
83
+
84
+ // src/core/cache.ts
85
+ import fs from "fs";
86
+ import path from "path";
87
+
88
+ // src/core/sqlite.ts
89
+ import { createRequire } from "module";
90
+ var isBun = typeof globalThis.Bun !== "undefined";
91
+ function openDatabase(filepath) {
92
+ if (isBun) {
93
+ const req2 = createRequire(import.meta.url);
94
+ const mod = req2("bun:sqlite");
95
+ const Database2 = mod.Database ?? mod.default ?? mod;
96
+ const raw = new Database2(filepath);
97
+ return {
98
+ prepare: (sql) => {
99
+ const stmt = raw.query(sql);
100
+ return {
101
+ run: (...params) => stmt.run(...params),
102
+ get: (...params) => stmt.get(...params),
103
+ all: (...params) => stmt.all(...params)
104
+ };
105
+ },
106
+ exec: (sql) => raw.run(sql),
107
+ pragma: (p) => raw.run(`PRAGMA ${p}`),
108
+ transaction: (fn) => raw.transaction(fn),
109
+ close: () => raw.close()
110
+ };
111
+ }
112
+ const req = createRequire(import.meta.url);
113
+ const Database = req("better-sqlite3");
114
+ return new Database(filepath);
115
+ }
116
+
117
+ // src/core/cache.ts
118
+ var TranslationCache = class {
119
+ db;
120
+ cacheDir;
121
+ // Prepared statements (lazily initialized)
122
+ _stmts;
123
+ constructor(cacheDir) {
124
+ this.cacheDir = cacheDir;
125
+ fs.mkdirSync(cacheDir, { recursive: true });
126
+ const dbPath = path.join(cacheDir, "translations.db");
127
+ this.db = openDatabase(dbPath);
128
+ this.db.pragma("journal_mode = WAL");
129
+ this.db.pragma("busy_timeout = 5000");
130
+ this.db.pragma("synchronous = NORMAL");
131
+ this.db.pragma("foreign_keys = ON");
132
+ this.db.exec(`
133
+ CREATE TABLE IF NOT EXISTS sources (
134
+ key TEXT PRIMARY KEY NOT NULL,
135
+ text TEXT NOT NULL,
136
+ type TEXT NOT NULL DEFAULT 'paragraph'
137
+ ) WITHOUT ROWID;
138
+
139
+ CREATE TABLE IF NOT EXISTS source_files (
140
+ key TEXT NOT NULL,
141
+ file TEXT NOT NULL,
142
+ line INTEGER NOT NULL,
143
+ version TEXT NOT NULL DEFAULT 'latest',
144
+ PRIMARY KEY (version, key, file, line)
145
+ ) WITHOUT ROWID;
146
+
147
+ CREATE TABLE IF NOT EXISTS translations (
148
+ lang TEXT NOT NULL,
149
+ key TEXT NOT NULL,
150
+ value TEXT NOT NULL,
151
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
152
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
153
+ PRIMARY KEY (lang, key)
154
+ ) WITHOUT ROWID;
155
+
156
+ CREATE INDEX IF NOT EXISTS idx_translations_lang ON translations(lang);
157
+ CREATE INDEX IF NOT EXISTS idx_source_files_version ON source_files(version);
158
+ CREATE INDEX IF NOT EXISTS idx_source_files_file ON source_files(version, file);
159
+ `);
160
+ }
161
+ get stmts() {
162
+ if (!this._stmts) {
163
+ this._stmts = {
164
+ getTranslation: this.db.prepare(
165
+ "SELECT value FROM translations WHERE lang = ? AND key = ?"
166
+ ),
167
+ setTranslation: this.db.prepare(`
168
+ INSERT INTO translations (lang, key, value)
169
+ VALUES (?, ?, ?)
170
+ ON CONFLICT(lang, key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
171
+ `),
172
+ deleteTranslation: this.db.prepare(
173
+ "DELETE FROM translations WHERE lang = ? AND key = ?"
174
+ ),
175
+ countByLang: this.db.prepare(
176
+ "SELECT COUNT(*) as count FROM translations WHERE lang = ?"
177
+ ),
178
+ allByLang: this.db.prepare(
179
+ "SELECT key, value FROM translations WHERE lang = ?"
180
+ ),
181
+ keysByLang: this.db.prepare(
182
+ "SELECT key FROM translations WHERE lang = ?"
183
+ ),
184
+ setSource: this.db.prepare(`
185
+ INSERT INTO sources (key, text, type) VALUES (?, ?, ?)
186
+ ON CONFLICT(key) DO UPDATE SET text = excluded.text, type = excluded.type
187
+ `),
188
+ getSource: this.db.prepare(
189
+ "SELECT text, type FROM sources WHERE key = ?"
190
+ ),
191
+ clearSourceFiles: this.db.prepare(
192
+ "DELETE FROM source_files WHERE version = ?"
193
+ ),
194
+ upsertSourceFile: this.db.prepare(`
195
+ INSERT OR IGNORE INTO source_files (key, file, line, version) VALUES (?, ?, ?, ?)
196
+ `),
197
+ deleteSourceFilesByLang: this.db.prepare(
198
+ "DELETE FROM source_files WHERE version = ?"
199
+ )
200
+ };
201
+ }
202
+ return this._stmts;
203
+ }
204
+ // ── Public API (backward-compatible with JSONL version) ──
205
+ /**
206
+ * Load cache for a language. No-op for SQLite (data is always available).
207
+ * Kept for API compatibility.
208
+ */
209
+ load(_lang) {
210
+ }
211
+ /**
212
+ * Save cache for a language. No-op for SQLite (writes are immediate).
213
+ * Kept for API compatibility.
214
+ */
215
+ save(_lang) {
216
+ }
217
+ /** Get a cached translation */
218
+ get(lang, md5) {
219
+ const row = this.stmts.getTranslation.get(lang, md5);
220
+ return row?.value;
221
+ }
222
+ /** Set a cached translation (writes immediately to disk) */
223
+ set(lang, md5, translation) {
224
+ this.stmts.setTranslation.run(lang, md5, translation);
225
+ }
226
+ /**
227
+ * Set a cached translation with its EN source text.
228
+ * This also stores the source in the `sources` table for dashboard use.
229
+ */
230
+ setWithSource(lang, md5, translation, sourceText, sourceType = "paragraph") {
231
+ this.db.transaction(() => {
232
+ this.stmts.setSource.run(md5, sourceText, sourceType);
233
+ this.stmts.setTranslation.run(lang, md5, translation);
234
+ })();
235
+ }
236
+ /** Delete a cached translation */
237
+ delete(lang, md5) {
238
+ const result = this.stmts.deleteTranslation.run(lang, md5);
239
+ return result.changes > 0;
240
+ }
241
+ /** Iterate all entries for a language */
242
+ *entries(lang) {
243
+ const rows = this.stmts.allByLang.all(lang);
244
+ for (const row of rows) {
245
+ yield [row.key, { v: row.value, src: [] }];
246
+ }
247
+ }
248
+ /** Get all cached keys for a language as a Set (fast for coverage checks) */
249
+ keys(lang) {
250
+ const rows = this.stmts.keysByLang.all(lang);
251
+ return new Set(rows.map((r) => r.key));
252
+ }
253
+ /** Remove entries not in the provided set of md5s */
254
+ prune(lang, usedMd5s) {
255
+ const allKeys = this.keys(lang);
256
+ let removed = 0;
257
+ const deleteStmt = this.stmts.deleteTranslation;
258
+ this.db.transaction(() => {
259
+ for (const md5 of allKeys) {
260
+ if (!usedMd5s.has(md5)) {
261
+ deleteStmt.run(lang, md5);
262
+ removed++;
263
+ }
264
+ }
265
+ })();
266
+ return removed;
267
+ }
268
+ /** Get cache stats for a language */
269
+ stats(lang) {
270
+ const row = this.stmts.countByLang.get(lang);
271
+ return { size: row?.count ?? 0 };
272
+ }
273
+ // ── Source tracking ──
274
+ /**
275
+ * Update source locations for a md5 hash.
276
+ * In SQLite mode, this inserts into source_files table.
277
+ */
278
+ updateSource(_lang, md5, filePath, line, version = "latest") {
279
+ this.stmts.upsertSourceFile.run(md5, filePath, line, version);
280
+ }
281
+ /** Clear all source file mappings for a version (call before a full rebuild) */
282
+ clearSources(_lang, version = "latest") {
283
+ this.stmts.clearSourceFiles.run(version);
284
+ }
285
+ /** Store an EN source text in the sources table */
286
+ setSource(md5, text, type = "paragraph") {
287
+ this.stmts.setSource.run(md5, text, type);
288
+ }
289
+ /** Get an EN source text from the sources table */
290
+ getSource(md5) {
291
+ const row = this.stmts.getSource.get(md5);
292
+ return row ?? void 0;
293
+ }
294
+ /**
295
+ * Get all untranslated source keys for a language and version.
296
+ * Returns keys with their EN source text and type.
297
+ */
298
+ untranslatedKeys(lang, version = "latest", limit = 0, files) {
299
+ let fileFilter = "";
300
+ const params = [lang, version];
301
+ if (files && files.length > 0) {
302
+ fileFilter = ` AND sf.file IN (${files.map(() => "?").join(",")})`;
303
+ params.push(...files);
304
+ }
305
+ const sql = `
306
+ SELECT DISTINCT s.key, s.text, s.type
307
+ FROM source_files sf
308
+ JOIN sources s ON s.key = sf.key
309
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
310
+ WHERE sf.version = ? AND t.key IS NULL${fileFilter}
311
+ ${limit > 0 ? `LIMIT ${limit}` : ""}
312
+ `;
313
+ return this.db.prepare(sql).all(...params);
314
+ }
315
+ // ── Dashboard queries ──
316
+ /** Get translation stats for all languages */
317
+ allLangStats() {
318
+ return this.db.prepare("SELECT lang, COUNT(*) as count FROM translations GROUP BY lang").all();
319
+ }
320
+ /**
321
+ * Get file-level coverage for a version and language.
322
+ * Returns how many source nodes each file has and how many are translated.
323
+ */
324
+ fileCoverage(version, lang) {
325
+ return this.db.prepare(
326
+ `
327
+ SELECT
328
+ sf.file,
329
+ COUNT(DISTINCT sf.key) as total,
330
+ COUNT(DISTINCT t.key) as translated
331
+ FROM source_files sf
332
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
333
+ WHERE sf.version = ?
334
+ GROUP BY sf.file
335
+ ORDER BY sf.file
336
+ `
337
+ ).all(lang, version);
338
+ }
339
+ /**
340
+ * Get node-level detail for a specific file: EN source + translation side by side.
341
+ */
342
+ fileDetail(version, lang, file) {
343
+ return this.db.prepare(
344
+ `
345
+ SELECT
346
+ s.key,
347
+ s.text as source,
348
+ s.type,
349
+ t.value as translation,
350
+ sf.line
351
+ FROM source_files sf
352
+ JOIN sources s ON s.key = sf.key
353
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
354
+ WHERE sf.version = ? AND sf.file = ?
355
+ ORDER BY sf.line
356
+ `
357
+ ).all(lang, version, file);
358
+ }
359
+ /** Get total source node count for a version */
360
+ sourceCount(version) {
361
+ const row = this.db.prepare(
362
+ "SELECT COUNT(DISTINCT key) as count FROM source_files WHERE version = ?"
363
+ ).get(version);
364
+ return row?.count ?? 0;
365
+ }
366
+ /**
367
+ * Get section-level stats for a version and language.
368
+ * Section is derived from file path prefix (docs/, blog/, learn/).
369
+ */
370
+ sectionStats(version, lang) {
371
+ return this.db.prepare(
372
+ `
373
+ WITH file_coverage AS (
374
+ SELECT
375
+ sf.file,
376
+ CASE
377
+ WHEN sf.file LIKE 'docs/%' THEN 'docs'
378
+ WHEN sf.file LIKE 'blog/%' THEN 'blog'
379
+ WHEN sf.file LIKE 'learn/%' THEN 'learn'
380
+ ELSE 'other'
381
+ END as section,
382
+ COUNT(DISTINCT sf.key) as total_nodes,
383
+ COUNT(DISTINCT t.key) as translated_nodes
384
+ FROM source_files sf
385
+ LEFT JOIN translations t ON t.key = sf.key AND t.lang = ?
386
+ WHERE sf.version = ?
387
+ GROUP BY sf.file
388
+ )
389
+ SELECT
390
+ section,
391
+ COUNT(*) as totalFiles,
392
+ SUM(CASE WHEN translated_nodes = total_nodes THEN 1 ELSE 0 END) as translatedFiles,
393
+ SUM(total_nodes) as totalNodes,
394
+ SUM(translated_nodes) as translatedNodes
395
+ FROM file_coverage
396
+ GROUP BY section
397
+ ORDER BY section
398
+ `
399
+ ).all(lang, version);
400
+ }
401
+ // ── Export / Import ──
402
+ /** Export cache to JSONL format (for backward compatibility / git tracking) */
403
+ exportJsonl(lang, outputPath) {
404
+ const rows = this.db.prepare(
405
+ "SELECT key, value FROM translations WHERE lang = ? ORDER BY key"
406
+ ).all(lang);
407
+ const lines = [];
408
+ for (const row of rows) {
409
+ const srcs = this.db.prepare(
410
+ "SELECT file || ':' || line as loc FROM source_files WHERE key = ? ORDER BY file, line"
411
+ ).all(row.key);
412
+ const entry = { k: row.key, v: row.value };
413
+ if (srcs.length > 0) {
414
+ entry.src = srcs.map((s) => s.loc);
415
+ }
416
+ lines.push(JSON.stringify(entry));
417
+ }
418
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
419
+ fs.writeFileSync(outputPath, `${lines.join("\n")}
420
+ `, "utf8");
421
+ return rows.length;
422
+ }
423
+ /** Import translations from a JSONL file */
424
+ importJsonl(lang, inputPath) {
425
+ if (!fs.existsSync(inputPath)) return 0;
426
+ const lines = fs.readFileSync(inputPath, "utf8").split("\n");
427
+ let count = 0;
428
+ this.db.transaction(() => {
429
+ for (const line of lines) {
430
+ const trimmed = line.trim();
431
+ if (!trimmed) continue;
432
+ const entry = JSON.parse(trimmed);
433
+ this.stmts.setTranslation.run(lang, entry.k, entry.v);
434
+ count++;
435
+ }
436
+ })();
437
+ return count;
438
+ }
439
+ /** Export a readable markdown index for IDE navigation */
440
+ exportIndex(lang) {
441
+ const indexPath = path.join(this.cacheDir, `${lang}.index.md`);
442
+ const rows = this.db.prepare(
443
+ `
444
+ SELECT t.key, t.value,
445
+ GROUP_CONCAT(sf.file || ':' || sf.line, char(10)) as sources
446
+ FROM translations t
447
+ LEFT JOIN source_files sf ON sf.key = t.key
448
+ WHERE t.lang = ?
449
+ GROUP BY t.key
450
+ HAVING sources IS NOT NULL
451
+ ORDER BY t.key
452
+ `
453
+ ).all(lang);
454
+ const lines = [`# Translation Index (${lang})`, ""];
455
+ for (const row of rows) {
456
+ if (!row.sources) continue;
457
+ const preview = row.value.split("\n")[0].substring(0, 80);
458
+ lines.push(`## \`${row.key}\` ${preview}`);
459
+ lines.push("");
460
+ for (const src of row.sources.split("\n")) {
461
+ const [filePath] = src.split(":");
462
+ lines.push(
463
+ `- [${src}](../content/en/${filePath}#L${src.split(":")[1]})`
464
+ );
465
+ }
466
+ lines.push("");
467
+ }
468
+ fs.writeFileSync(indexPath, lines.join("\n"), "utf8");
469
+ }
470
+ /** Close the database connection */
471
+ close() {
472
+ this._stmts = void 0;
473
+ this.db.close();
474
+ }
475
+ };
476
+
477
+ // src/core/frontmatter.ts
478
+ import { parseDocument } from "yaml";
479
+ function getNestedValue(doc, path2) {
480
+ const parts = path2.split(".");
481
+ let current = doc;
482
+ for (const part of parts) {
483
+ if (current == null) return void 0;
484
+ if (typeof current.get === "function") {
485
+ current = current.get(part);
486
+ } else if (typeof current === "object") {
487
+ current = current[part];
488
+ } else {
489
+ return void 0;
490
+ }
491
+ }
492
+ return current;
493
+ }
494
+ function setNestedValue(doc, path2, value) {
495
+ const parts = path2.split(".");
496
+ if (parts.length === 1) {
497
+ if (doc.has(parts[0])) doc.set(parts[0], value);
498
+ return;
499
+ }
500
+ let current = doc;
501
+ for (let i = 0; i < parts.length - 1; i++) {
502
+ if (typeof current.get === "function") {
503
+ current = current.get(parts[i]);
504
+ } else {
505
+ return;
506
+ }
507
+ }
508
+ const lastKey = parts[parts.length - 1];
509
+ if (typeof current.set === "function") {
510
+ current.set(lastKey, value);
511
+ }
512
+ }
513
+ function extractTranslatableFields(frontmatterText) {
514
+ const yaml = frontmatterText.replace(/^---\n/, "").replace(/\n---$/, "");
515
+ const doc = parseDocument(yaml);
516
+ const fields = {};
517
+ for (const field of FRONTMATTER_TRANSLATABLE_FIELDS) {
518
+ const value = getNestedValue(doc, field);
519
+ if (typeof value === "string" && value.trim()) {
520
+ fields[field] = value;
521
+ }
522
+ }
523
+ return fields;
524
+ }
525
+ function reconstructFrontmatter(frontmatterText, translatedFields) {
526
+ const yaml = frontmatterText.replace(/^---\n/, "").replace(/\n---$/, "");
527
+ const doc = parseDocument(yaml);
528
+ for (const [field, value] of Object.entries(translatedFields)) {
529
+ setNestedValue(doc, field, value);
530
+ }
531
+ return `---
532
+ ${doc.toString().trimEnd()}
533
+ ---`;
534
+ }
535
+ export {
536
+ TranslationCache,
537
+ assemble,
538
+ defineConfig,
539
+ extractTranslatableFields,
540
+ flattenSources,
541
+ loadConfig,
542
+ normalize,
543
+ parseMdx,
544
+ reconstructFrontmatter
545
+ };