smartloc 1.2.1 → 2.0.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.
Files changed (116) hide show
  1. package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
  2. package/.vscode/launch.json +65 -0
  3. package/.vscode/settings.json +14 -0
  4. package/.vscode/tasks.json +27 -0
  5. package/README.md +225 -0
  6. package/build/README.md +225 -0
  7. package/build/package.json +60 -0
  8. package/bun.lock +310 -0
  9. package/index.js +1 -26
  10. package/package.json +17 -18
  11. package/samples/express/main.ts +37 -0
  12. package/samples/express/package.json +10 -0
  13. package/samples/graphql/main.ts +73 -0
  14. package/samples/graphql/package.json +15 -0
  15. package/src/adapters/adapter-base.ts +58 -0
  16. package/src/adapters/index.ts +31 -0
  17. package/src/adapters/json.ts +18 -0
  18. package/src/adapters/xliff.ts +30 -0
  19. package/src/cli/cli.ts +137 -0
  20. package/src/cli/collect.ts +196 -0
  21. package/src/cli/interfaces.ts +7 -0
  22. package/src/cli/reconciliate.ts +119 -0
  23. package/src/cli/utils.ts +36 -0
  24. package/src/core/default-locale.tsx +54 -0
  25. package/src/core/interfaces.ts +49 -0
  26. package/{core/json-utils.js → src/core/json-utils.ts} +45 -34
  27. package/src/core/literal.ts +66 -0
  28. package/src/core/load.ts +82 -0
  29. package/src/core/locale-list.ts +128 -0
  30. package/src/core/locale.tsx +75 -0
  31. package/src/core/smartloc.tsx +338 -0
  32. package/src/core/tag.ts +40 -0
  33. package/src/core/utils.tsx +24 -0
  34. package/src/express.ts +93 -0
  35. package/{graphql.js → src/graphql.ts} +80 -68
  36. package/src/index.ts +7 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/load.ts +32 -0
  39. package/test/collect.spec.ts +45 -0
  40. package/test/reconciliation.spec.ts +132 -0
  41. package/test/serialization.spec.ts +162 -0
  42. package/test/translation.spec.ts +61 -0
  43. package/tsconfig.json +37 -0
  44. package/tslint.json +103 -0
  45. package/adapters/adapter-base.d.ts +0 -10
  46. package/adapters/adapter-base.js +0 -46
  47. package/adapters/adapter-base.js.map +0 -1
  48. package/adapters/index.d.ts +0 -3
  49. package/adapters/index.js +0 -33
  50. package/adapters/index.js.map +0 -1
  51. package/adapters/json.d.ts +0 -6
  52. package/adapters/json.js +0 -22
  53. package/adapters/json.js.map +0 -1
  54. package/adapters/xliff.d.ts +0 -6
  55. package/adapters/xliff.js +0 -32
  56. package/adapters/xliff.js.map +0 -1
  57. package/cli/cli.d.ts +0 -2
  58. package/cli/cli.js +0 -128
  59. package/cli/cli.js.map +0 -1
  60. package/cli/collect.d.ts +0 -12
  61. package/cli/collect.js +0 -151
  62. package/cli/collect.js.map +0 -1
  63. package/cli/interfaces.d.ts +0 -25
  64. package/cli/interfaces.js +0 -3
  65. package/cli/interfaces.js.map +0 -1
  66. package/cli/reconciliate.d.ts +0 -6
  67. package/cli/reconciliate.js +0 -108
  68. package/cli/reconciliate.js.map +0 -1
  69. package/cli/utils.d.ts +0 -7
  70. package/cli/utils.js +0 -54
  71. package/cli/utils.js.map +0 -1
  72. package/core/base-loc.d.ts +0 -3
  73. package/core/base-loc.js +0 -20
  74. package/core/base-loc.js.map +0 -1
  75. package/core/default-locale.d.ts +0 -8
  76. package/core/default-locale.js +0 -42
  77. package/core/default-locale.js.map +0 -1
  78. package/core/interfaces.d.ts +0 -32
  79. package/core/interfaces.js +0 -3
  80. package/core/interfaces.js.map +0 -1
  81. package/core/json-utils.d.ts +0 -19
  82. package/core/json-utils.js.map +0 -1
  83. package/core/literal.d.ts +0 -9
  84. package/core/literal.js +0 -52
  85. package/core/literal.js.map +0 -1
  86. package/core/locale-list.d.ts +0 -19
  87. package/core/locale-list.js +0 -84
  88. package/core/locale-list.js.map +0 -1
  89. package/core/locale.d.ts +0 -13
  90. package/core/locale.js +0 -34
  91. package/core/locale.js.map +0 -1
  92. package/core/locstr-array.d.ts +0 -10
  93. package/core/locstr-array.js +0 -48
  94. package/core/locstr-array.js.map +0 -1
  95. package/core/smartloc.d.ts +0 -64
  96. package/core/smartloc.js +0 -222
  97. package/core/smartloc.js.map +0 -1
  98. package/core/tag.d.ts +0 -5
  99. package/core/tag.js +0 -17
  100. package/core/tag.js.map +0 -1
  101. package/core/utils.d.ts +0 -1
  102. package/core/utils.js +0 -13
  103. package/core/utils.js.map +0 -1
  104. package/express.d.ts +0 -12
  105. package/express.js +0 -85
  106. package/express.js.map +0 -1
  107. package/graphql.d.ts +0 -12
  108. package/graphql.js.map +0 -1
  109. package/index.d.ts +0 -7
  110. package/index.js.map +0 -1
  111. package/node/index.d.ts +0 -1
  112. package/node/index.js +0 -14
  113. package/node/index.js.map +0 -1
  114. package/node/load.d.ts +0 -12
  115. package/node/load.js +0 -44
  116. package/node/load.js.map +0 -1
@@ -0,0 +1,58 @@
1
+ import { IFormatAdapter } from '../cli/interfaces';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import type { Translation } from '../core/load';
5
+
6
+ export abstract class AdapterBase implements IFormatAdapter {
7
+
8
+ constructor(private target: string | null) {
9
+ }
10
+
11
+ async loadFile(filePath: string): Promise<Translation> {
12
+ if (!await fs.pathExists(filePath)) {
13
+ throw new Error(`File ${filePath} does not exist`);
14
+ }
15
+ const content = await fs.readFile(filePath, 'utf8');
16
+ return await this.doLoadFile(content);
17
+ }
18
+
19
+ async loadLocale(locale: string): Promise<Translation> {
20
+ if (!this.target || !await fs.pathExists(this.target)) {
21
+ return {
22
+ resources: {},
23
+ targetLanguage: locale,
24
+ };
25
+ }
26
+ return await this.loadFile(path.join(this.target, `${locale}.json`));
27
+ }
28
+
29
+ protected abstract doLoadFile(content: string): Promise<Translation>;
30
+
31
+ async loadLocales(locales: string[]): Promise<Translation[]> {
32
+ if (!this.target || !await fs.pathExists(this.target)) {
33
+ return locales.map<Translation>(x => ({
34
+ resources: {},
35
+ targetLanguage: x,
36
+ }));
37
+ }
38
+ const ret: Translation[] = [];
39
+ for (const f of locales) {
40
+ const t = await this.loadFile(path.join(this.target, `${f}.json`));
41
+ t.targetLanguage = f;
42
+ ret.push(t);
43
+ }
44
+ return ret;
45
+ }
46
+
47
+ async write(translation: Translation) {
48
+ if (!this.target) {
49
+ throw new Error('Target directory not set');
50
+ }
51
+ await fs.ensureDir(this.target);
52
+ const content = await this.contentFor(translation);
53
+ const toFile = path.join(this.target, translation.targetLanguage + '.json');
54
+ await fs.writeFile(toFile, content, 'utf8');
55
+ }
56
+
57
+ protected abstract contentFor(translation: Translation): Promise<string>;
58
+ }
@@ -0,0 +1,31 @@
1
+ import type { IFormatAdapter } from '../cli/interfaces';
2
+ import { XliffAdapter } from './xliff';
3
+ import { JsonAdapter } from './json';
4
+ import type { Translation } from '../core/load';
5
+
6
+ export function getAdapter(format: string, dir: string): IFormatAdapter {
7
+ switch (format) {
8
+ case 'xliff':
9
+ return new XliffAdapter(dir);
10
+ case 'json':
11
+ return new JsonAdapter(dir);
12
+ default:
13
+ throw new Error('Unknown translation format: ' + format);
14
+ }
15
+ }
16
+
17
+ export async function loadLocaleFrom(path: string): Promise<Translation | null> {
18
+ const [ext] = /\.[a-z]+$/.exec(path.toLowerCase()) || [null];
19
+ let adapter: IFormatAdapter;
20
+ switch (ext) {
21
+ case '.json':
22
+ adapter = new JsonAdapter(null);
23
+ break;
24
+ case '.xlf':
25
+ adapter = new XliffAdapter(null);
26
+ break;
27
+ default:
28
+ return null;
29
+ }
30
+ return await adapter.loadFile(path);
31
+ }
@@ -0,0 +1,18 @@
1
+ import { type Translation, type GroupedTranslation, ungroupTranslation } from '../core/load';
2
+ import stableJson from 'json-stable-stringify';
3
+ import { AdapterBase } from './adapter-base';
4
+ import { toGroupedTranslation } from '../cli/utils';
5
+
6
+ export class JsonAdapter extends AdapterBase {
7
+
8
+ protected async doLoadFile(content: string): Promise<Translation> {
9
+ const parsed = JSON.parse(content) as GroupedTranslation;
10
+ return ungroupTranslation(parsed);
11
+ }
12
+
13
+ protected async contentFor(translation: Translation) {
14
+ const data = toGroupedTranslation(translation);
15
+ const content = stableJson(data, { space: ' ' });
16
+ return content!;
17
+ }
18
+ }
@@ -0,0 +1,30 @@
1
+ // @ts-ignore
2
+ import js2xliff from 'xliff/js2xliff';
3
+ // @ts-ignore
4
+ import xliff2js from 'xliff/xliff2js';
5
+ import type { Translation, GroupedTranslation } from '../core/load';
6
+ import { toGroupedTranslation } from '../cli/utils';
7
+ import { ungroupTranslation } from '../core/load';
8
+ import { AdapterBase } from './adapter-base';
9
+
10
+ export class XliffAdapter extends AdapterBase {
11
+
12
+ protected async doLoadFile(content: string): Promise<Translation> {
13
+ const parsed = await new Promise<GroupedTranslation>((res, rej) => xliff2js(content, (err: any, val: any) => err ? rej(err) : res(val)));
14
+ return ungroupTranslation(parsed);
15
+ }
16
+
17
+ protected async contentFor(translation: Translation) {
18
+ const data = toGroupedTranslation(translation);
19
+ return await new Promise<string>((res, rej) => js2xliff(data, async (err: any, result: any) => {
20
+ if (err) {
21
+ return rej(err);
22
+ }
23
+ try {
24
+ res(result);
25
+ } catch (e) {
26
+ rej(e);
27
+ }
28
+ }));
29
+ }
30
+ }
package/src/cli/cli.ts ADDED
@@ -0,0 +1,137 @@
1
+ #!/usr/bin/env node
2
+
3
+ import yargs, { Argv } from 'yargs';
4
+ import path from 'path';
5
+ import { collect } from './collect';
6
+ import { reconciliate } from './reconciliate';
7
+ import { getAdapter } from '../adapters';
8
+
9
+ // === arguments parsing ===
10
+
11
+ function isValidLocale(x: string) {
12
+ return typeof x === 'string' && !!x && /^[a-zA-Z]+(\-[a-zA-Z]+)?$/.test(x);
13
+ }
14
+
15
+ function defaultArgs<T>(b: Argv<T>): Argv<T & { format: string; outDir: string; }> {
16
+ return b.positional('format', {
17
+ describe: 'The output format',
18
+ choices: ['json', 'xliff'],
19
+ default: 'xliff',
20
+ })
21
+ .positional('outDir', {
22
+ describe: 'The translation output directory',
23
+ default: 'i18n',
24
+ normalize: true,
25
+ })
26
+ }
27
+
28
+ yargs
29
+ .scriptName('smartloc')
30
+ .command('add', 'Add a new locale', b => defaultArgs(b).demandCommand()
31
+ , args => {
32
+ console.log(args);
33
+ })
34
+ .command('collect', 'Collects all strings to translate', b => defaultArgs(b)
35
+ .positional('source', {
36
+ describe: 'The source directory where to look for translations (defaults to current working directory)',
37
+ type: 'string',
38
+ normalize: true,
39
+ })
40
+ .positional('defaultLocale', {
41
+ description: 'Sets the default locale (the locale used in code for your strings)',
42
+ type: 'string',
43
+ coerce: x => {
44
+ if (!isValidLocale(x)) {
45
+ console.error('Invalid locale ' + x);
46
+ process.exit(1);
47
+ }
48
+ return x;
49
+ },
50
+ })
51
+ .positional('generateDefault', {
52
+ description: 'Also generates a translation file for the default locale',
53
+ type: 'boolean',
54
+ })
55
+ .positional('generateDefault', {
56
+ description: 'Also generates the default locale translation file (which does not need to be translated)',
57
+ type: 'boolean',
58
+ })
59
+ .positional('locales', {
60
+ description: 'Comma separated list of locales to generate (others than the default one)',
61
+ type: 'string',
62
+ coerce: x => {
63
+ if (typeof x !== 'string' || !x || !/^[a-zA-Z]+(\-[a-zA-Z]+)?$/.test(x)) {
64
+ console.error('Invalid locale ' + x);
65
+ process.exit(1);
66
+ }
67
+ return x;
68
+ },
69
+ })
70
+ , async args => {
71
+ try {
72
+
73
+ const locales = args.locales?.split(',')
74
+ .map(x => x.trim())
75
+ .filter(x => x !== args.defaultLocale);
76
+ if (!locales?.length) {
77
+ console.error('You must provide at least one locale thats different from your default locale')
78
+ process.exit(1);
79
+ }
80
+ if (locales.some(x => !isValidLocale(x))) {
81
+ console.error('--locales argument is invalid')
82
+ process.exit(1);
83
+ }
84
+
85
+ // create adapter
86
+ const outdir = path.join(process.cwd(), args.outDir);
87
+ const adapter = getAdapter(args.format, outdir);
88
+
89
+ // collect from sources
90
+ console.log('Collecting from sources...');
91
+ const { collected, files } = await collect(args.source || '.', adapter, args.defaultLocale);
92
+ console.log(`...found ${Object.keys(collected.resources).length} translatable items over ${files} files with default language ${collected.targetLanguage}`);
93
+
94
+
95
+ // load other locales
96
+ const others = (await adapter.loadLocales(locales))
97
+ .filter(x => x.targetLanguage !== collected.targetLanguage);
98
+
99
+ console.log(`Updating ${others.length} other translations...`);
100
+
101
+ // reconciliate existing
102
+ for (const l of others) {
103
+ const collectedCopy = JSON.parse(JSON.stringify(collected));
104
+ const { dirty, missing, same } = reconciliate(l, collectedCopy);
105
+ const feats: string[] = [];
106
+ if (dirty) {
107
+ feats.push(`${dirty} dirty translations (source changed)`);
108
+ }
109
+ if (missing) {
110
+ feats.push(`${missing} missing translations`);
111
+ }
112
+ if (same.length) {
113
+ feats.push(`${same.length} translations matching original (ex: ${same[0]})`);
114
+ }
115
+ if (!feats.length) {
116
+ console.log(`${l.targetLanguage} is OK ♥`);
117
+ } else {
118
+ console.log(`${l.targetLanguage} has ${feats.join(', ')}`);
119
+ }
120
+ }
121
+
122
+ // write others
123
+ console.log(`Writing results...`);
124
+ for (const l of others) {
125
+ await adapter.write(l);
126
+ }
127
+
128
+ if (args.defaultLocale) {
129
+ await adapter.write(collected);
130
+ }
131
+ console.log(`I'm done ♥`);
132
+ } catch (e) {
133
+ console.error('Command failed', e);
134
+ process.exit(1);
135
+ }
136
+ })
137
+ .argv;
@@ -0,0 +1,196 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import giparser from 'gitignore-parser';
4
+ import type { CollectResult, Translation } from '../core/load';
5
+ import type { IFormatAdapter } from './interfaces';
6
+ import { autoGenerateId } from '../core/utils';
7
+ import util from 'util';
8
+ export interface Loc {
9
+ id: string;
10
+ file: string | undefined;
11
+ line: number;
12
+ source: string;
13
+ plural?: boolean;
14
+ }
15
+
16
+ export async function collect(source: string, adapter: IFormatAdapter, forceLocale?: string): Promise<{ collected: Translation; files: number }> {
17
+ source = source
18
+ ? path.join(process.cwd(), source)
19
+ : process.cwd();
20
+ // parse gitignore
21
+ // todo: parse .gitignore files in nested directories.
22
+ let ignore: { accepts(path: string): boolean; denies(path: string): boolean; };
23
+ const gitIgnorePath = path.join(source, '.gitignore');
24
+ if (await fs.pathExists(gitIgnorePath)) {
25
+ const content = await fs.readFile(gitIgnorePath, 'utf8');
26
+ ignore = giparser.compile(content);
27
+ } else {
28
+ ignore = giparser.compile('');
29
+ }
30
+
31
+
32
+ async function* walk(dirRelative: string): AsyncIterableIterator<string> {
33
+ try {
34
+ const files = await fs.readdir(path.join(source, dirRelative));
35
+ for (const file of files) {
36
+ const filepath = path.join(source, dirRelative, file);
37
+ const fileRelative = path.join(dirRelative, file);
38
+ if (ignore.denies(fileRelative)) {
39
+ continue;
40
+ }
41
+ const stats = await fs.stat(filepath);
42
+ if (stats.isDirectory()) {
43
+ yield* walk(fileRelative);
44
+ } else {
45
+ if (ignore.accepts(fileRelative)) {
46
+ yield fileRelative;
47
+ }
48
+ }
49
+ }
50
+ } catch (e) {
51
+ console.error('Failed to read directory ' + dirRelative);
52
+ }
53
+ }
54
+
55
+ const ids = new Map<string, Loc>();
56
+ const all: Loc[] = [];
57
+ let defaultLocale: { val: string; file: string; } | undefined = undefined;
58
+ let fcount = 0;
59
+ for await (const f of walk('')) {
60
+ const ext = f.toLowerCase();
61
+ if (!ext.endsWith('.js') && !ext.endsWith('.ts') && !ext.endsWith('.jsx') && !ext.endsWith('.tsx')) {
62
+ continue;
63
+ }
64
+ fcount++;
65
+ try {
66
+ const content = await fs.readFile(path.join(source, f), 'utf8');
67
+
68
+ // === try to find setDefaultLocale()
69
+ if (!forceLocale) {
70
+ const defLoc = /\bsetDefaultLocale\s*\(\s*('|")([a-zA-Z\-]+)('|")\s*\)/.exec(content);
71
+ const newDefLoc = defLoc?.[2];
72
+ if (newDefLoc) {
73
+ if (defaultLocale && defaultLocale.val !== newDefLoc) {
74
+ console.error(`Ambiguous default locale. Found '${newDefLoc}' in ${f}, but was defined already defined as '${defaultLocale.val}' in ${defaultLocale.file}. Please use --locale:${defaultLocale.val}`)
75
+ }
76
+ defaultLocale = {
77
+ file: f,
78
+ val: newDefLoc,
79
+ };
80
+ }
81
+ }
82
+
83
+ collectFromSource(content, ids, all, f);
84
+
85
+ } catch (e) {
86
+ console.error('Failed to read file ' + f + ': ' + util.inspect(e));
87
+ }
88
+ }
89
+
90
+ // === load default locale ===
91
+ const targetLanguage = defaultLocale?.val || forceLocale;
92
+ const origFile = targetLanguage ? await adapter.loadLocale(targetLanguage) : null;
93
+
94
+ // === WRITE RESULT ===
95
+ const result: CollectResult = {};
96
+ for (const v of all) {
97
+ if (v.plural) {
98
+
99
+ // Pluralizable strings must also be "transalted" in source language,
100
+ // since the code will only contain the pluralized form
101
+ // => they must be marked as dirty if need be.
102
+ const orig = origFile?.resources?.[v.id];
103
+ let singular: string | null = null;
104
+ let dirty: boolean | undefined = undefined;
105
+ if (orig?.target && typeof orig.target !== 'string') {
106
+ // if the plural has changed, or if there is no singlar in orig file,
107
+ // then it must be fixed
108
+ if (!orig.target.singular || orig.target.plural !== v.source) {
109
+ dirty = true;
110
+ }
111
+ singular = orig.target?.singular ?? null;
112
+ }
113
+
114
+ result[v.id] = {
115
+ target: {
116
+ singular,
117
+ plural: v.source,
118
+ },
119
+ dirty,
120
+ };
121
+ } else {
122
+ // simple translations are never dirty: code is law
123
+ result[v.id] = {
124
+ target: v.source,
125
+ };
126
+ }
127
+ }
128
+
129
+ if (!targetLanguage) {
130
+ console.error('Cannot infer the default locale. Either call setDefaultLocale() in analyzed sources, or set the --defaultLocale argument');
131
+ process.exit(1);
132
+ }
133
+ const collected = {
134
+ resources: result,
135
+ targetLanguage,
136
+ };
137
+ return { collected, files: fcount };
138
+ }
139
+
140
+
141
+ export function collectFromSource(content: string, ids: Map<string, Loc>, all: Loc[], f?: string) {
142
+
143
+
144
+ // === parse loc() calls
145
+ const re = /\bloc(\.plural\([^\)]+\))?\s*(\(\s*('|")([^'"\n]+)('|")\s*(?:,\s*)?\))?\s*`([^`]*)`/g;
146
+ let m: RegExpExecArray | null;
147
+ while (m = re.exec(content)) {
148
+ const plural = m[1];
149
+ let id = m[4];
150
+ const val = m[6];
151
+
152
+ // parse format
153
+ const reFormat = /\$\{([^\}]+)\}/g;
154
+ let fm: RegExpExecArray | null;
155
+ let index = 0;
156
+ let cnt = 0;
157
+ const parts: string[] = [];
158
+ const idParts: string[] | null = id ? null : [];
159
+ while (fm = reFormat.exec(val!)) {
160
+ const literal = val!.substr(index, fm.index - index);
161
+ parts.push(literal);
162
+ idParts?.push(literal);
163
+ index = fm.index + fm[0].length;
164
+ parts.push('{', cnt.toString(), '}');
165
+ cnt++;
166
+ }
167
+ // add trailing
168
+ idParts?.push(val!.substr(index));
169
+ parts.push(val!.substr(index));
170
+
171
+
172
+ const raw = parts.join('');
173
+ id = id || autoGenerateId(idParts!);
174
+ const exists = ids.get(id);
175
+ // check no dupplicate
176
+ if (exists && exists.source !== raw) {
177
+ if (f === exists.file) {
178
+ console.error(`Duplicate translation id '${id}' found in ${f} but was already defined in ${exists.file}`);
179
+ } else {
180
+ console.error(`Duplicate translation id '${id}' found in ${f}`);
181
+ }
182
+ continue;
183
+ }
184
+
185
+
186
+ const found: Loc = {
187
+ file: f,
188
+ id,
189
+ line: 0, // todo
190
+ source: raw,
191
+ plural: plural ? true : undefined,
192
+ };
193
+ all.push(found);
194
+ ids.set(id, found);
195
+ }
196
+ }
@@ -0,0 +1,7 @@
1
+ import type { Translation } from '../core/load';
2
+ export interface IFormatAdapter {
3
+ write(data: Translation): Promise<void>;
4
+ loadLocales(locales: string[]): Promise<Translation[]>;
5
+ loadFile(path: string): Promise<Translation>;
6
+ loadLocale(locale: string): Promise<Translation>;
7
+ }
@@ -0,0 +1,119 @@
1
+ import type { Translation, CollectItem } from '../core/load';
2
+ import levenshtein from 'js-levenshtein';
3
+ import diacritics from 'diacritics';
4
+ import { deepEqual } from './utils';
5
+ import type { TranslationTarget } from '../core/interfaces';
6
+
7
+ const cache: Record<string, string> = {};
8
+ function asKey(t: TranslationTarget) {
9
+ const str = typeof t === 'string' ? t : t.plural;
10
+ if (cache[str]) {
11
+ return cache[str];
12
+ }
13
+ return cache[str] = diacritics.remove(str.toLowerCase().trim());
14
+ }
15
+
16
+ export function reconciliate(translations: Translation, source: Translation) {
17
+ // source is the new one
18
+ translations.sourceLanguage = source.targetLanguage;
19
+
20
+ // stores which translations do not exist in code anymore
21
+ const translatedLost: { [original: string]: CollectItem; } = {};
22
+
23
+ function updateWithOrig(trans: CollectItem, origId: string, src: CollectItem) {
24
+ if (trans.source && !deepEqual(trans.source, src.target)) {
25
+ trans.dirty = true;
26
+ }
27
+ trans.source = src.target ?? undefined;
28
+ translations.resources[origId] = trans;
29
+ // remove it from 'def'
30
+ delete source.resources[origId];
31
+ }
32
+
33
+ // update source lines
34
+ for (const [id, trans] of Object.entries(translations.resources)) {
35
+ const d = source.resources[id];
36
+ if (d) {
37
+ // already exists => update
38
+ updateWithOrig(trans, id, d);
39
+ } else {
40
+ // does not exist anymore => remove, but keep in mind.
41
+ delete translations.resources[id];
42
+ if (trans.source) {
43
+ translatedLost[asKey(trans.source)] = trans;
44
+ }
45
+ }
46
+ }
47
+
48
+ // for each NEW translations missing
49
+ // try to reconciliate with those which have changed id, but still same translation.
50
+ for (const [id, value] of Object.entries(source.resources)) {
51
+ if (!value.target) {
52
+ continue;
53
+ }
54
+ const cleanTarget = asKey(value.target);
55
+ if (translatedLost[cleanTarget]) {
56
+ updateWithOrig(translatedLost[cleanTarget], id, value);
57
+ }
58
+ }
59
+
60
+ // try to reconciliate with strings that look alike
61
+ // nb: avoid doing that when too much deleted translations: Levenshtein distance computation is expensive
62
+ const lostKeys = Object.keys(translatedLost);
63
+ if (Object.keys(source.resources).length * lostKeys.length < 5000 && lostKeys.length > 0) {
64
+ const reconc: { origId: string; orig: CollectItem; translatedLostSource: string; distance: number }[] = [];
65
+ for (const lk of lostKeys) {
66
+ reconc.push(...Object.entries(source.resources)
67
+ .map(([id, orig]) => {
68
+ // distance between the lost source & the new source
69
+ const target = orig.target ? asKey(orig.target) : null;
70
+ const distance = target ? levenshtein(lk, target) : Infinity;
71
+ return {
72
+ origId: id,
73
+ orig,
74
+ translatedLostSource: lk,
75
+ distance: distance,
76
+ }
77
+ })
78
+ // forbids to much change
79
+ .filter(x => x.distance < lk.length / 1.5)
80
+ );
81
+ }
82
+
83
+ reconc.sort((a, b) => a.distance - b.distance);
84
+ const done = new Set();
85
+ for (const d of reconc) {
86
+ if (done.has(d.origId)) {
87
+ continue;
88
+ }
89
+ done.add(d.origId);
90
+ updateWithOrig(translatedLost[d.translatedLostSource], d.origId, d.orig);
91
+ }
92
+ }
93
+
94
+ // finally, add new translations...
95
+ for (const [id, value] of Object.entries(source.resources)) {
96
+ translations.resources[id] = {
97
+ source: value.target ?? undefined,
98
+ };
99
+ }
100
+
101
+ // try to reconciliate
102
+
103
+ // count things
104
+ let missing = 0;
105
+ const same: string[] = [];
106
+ let dirty = 0;
107
+ for (const [k, c] of Object.entries(translations.resources)) {
108
+ if (!c.target) {
109
+ missing++;
110
+ c.target = null; // so it is serialized as json
111
+ } else if (c.target === c.source) {
112
+ same.push(k);
113
+ }
114
+ if (c.dirty) {
115
+ dirty++;
116
+ }
117
+ }
118
+ return { missing, same, dirty };
119
+ }
@@ -0,0 +1,36 @@
1
+ import fastDeepEqual from 'fast-deep-equal';
2
+ import type { CollectResult, GroupedTranslation, Translation } from '../core/load';
3
+
4
+ export function groupByNamespace(trans: CollectResult): {[namespace: string]: CollectResult} {
5
+ const result: {[namespace: string]: CollectResult} = {};
6
+ for (let [id, v] of Object.entries(trans)) {
7
+ let namespace = '$default';
8
+ const i = id.indexOf('.');
9
+ if (i > 0 && i < id.length - 2) {
10
+ namespace = id.substr(0, i);
11
+ id = id.substr(i + 1);
12
+ }
13
+ if (!result[namespace]) {
14
+ result[namespace] = {};
15
+ }
16
+ result[namespace][id] = v;
17
+ }
18
+ return result;
19
+ }
20
+
21
+ export function toGroupedTranslation(translation: Translation): GroupedTranslation {
22
+ return {
23
+ resources: groupByNamespace(translation.resources),
24
+ sourceLanguage: translation.sourceLanguage,
25
+ targetLanguage: translation.targetLanguage,
26
+ };
27
+ }
28
+
29
+
30
+ export function deepEqual<T>(obj1: T, obj2: T): boolean {
31
+ // if (typeof Bun !== 'undefined') {
32
+ // const inst = Bun;
33
+ // return inst.deepEquals(obj1, obj2);
34
+ // }
35
+ return fastDeepEqual(obj1, obj2);
36
+ }