Skip to content

Commit cbc87b5

Browse files
authored
feat: Cloud service providers & Translation.io (#1107)
Now with Lingui, you could integrate your favorite SaSS for syncing your translations using an internal process. Translation.io already introduced themselves and now you can sync Translation.io translations in your React codebases without anything else than `lingui extract`
1 parent 1144cc7 commit cbc87b5

6 files changed

Lines changed: 321 additions & 2 deletions

File tree

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"LICENSE",
3030
"README.md",
3131
"api",
32+
"services",
3233
"lingui.js",
3334
"lingui-*.js"
3435
],

packages/cli/src/lingui-extract.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export default function command(
6868
)
6969
}
7070

71+
// If service key is present in configuration, synchronize with cloud translation platform
72+
if (typeof config.service === 'object' && config.service.name && config.service.name.length) {
73+
import(`./services/${config.service.name}`)
74+
.then(module => module.default(config, options))
75+
.catch(err => console.error(`Can't load service module ${config.service.name}`, err))
76+
}
77+
7178
return true
7279
}
7380

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import fs from "fs"
2+
import { dirname } from "path"
3+
import PO from "pofile"
4+
import https from "https"
5+
import glob from "glob"
6+
import { format as formatDate } from "date-fns"
7+
8+
const getCreateHeaders = (language) => ({
9+
"POT-Creation-Date": formatDate(new Date(), "yyyy-MM-dd HH:mmxxxx"),
10+
"MIME-Version": "1.0",
11+
"Content-Type": "text/plain; charset=utf-8",
12+
"Content-Transfer-Encoding": "8bit",
13+
"X-Generator": "@lingui/cli",
14+
Language: language,
15+
})
16+
17+
// Main sync method, call "Init" or "Sync" depending on the project context
18+
export default function syncProcess(config, options) {
19+
if (config.format != 'po') {
20+
console.error(`\n----------\nTranslation.io service is only compatible with the "po" format. Please update your Lingui configuration accordingly.\n----------`)
21+
process.exit(1)
22+
}
23+
24+
const successCallback = (project) => {
25+
console.log(`\n----------\nProject successfully synchronized. Please use this URL to translate: ${project.url}\n----------`)
26+
}
27+
28+
const failCallback = (errors) => {
29+
console.error(`\n----------\nSynchronization with Translation.io failed: ${errors.join(', ')}\n----------`)
30+
}
31+
32+
init(config, options, successCallback, (errors) => {
33+
if (errors.length && errors[0] === 'This project has already been initialized.') {
34+
sync(config, options, successCallback, failCallback)
35+
}
36+
else {
37+
failCallback(errors)
38+
}
39+
})
40+
}
41+
42+
// Initialize project with source and existing translations (only first time!)
43+
// Cf. https://translation.io/docs/create-library#initialization
44+
function init(config, options, successCallback, failCallback) {
45+
const sourceLocale = config.sourceLocale || 'en'
46+
const targetLocales = config.locales.filter((value) => value != sourceLocale)
47+
const paths = poPathsPerLocale(config)
48+
49+
let segments = {}
50+
51+
targetLocales.forEach((targetLocale) => {
52+
segments[targetLocale] = []
53+
})
54+
55+
// Create segments from source locale PO items
56+
paths[sourceLocale].forEach((path) => {
57+
let raw = fs.readFileSync(path).toString()
58+
let po = PO.parse(raw)
59+
60+
po.items.filter((item) => !item['obsolete']).forEach((item) => {
61+
targetLocales.forEach((targetLocale) => {
62+
let newSegment = createSegmentFromPoItem(item)
63+
64+
segments[targetLocale].push(newSegment)
65+
})
66+
})
67+
})
68+
69+
// Add translations to segments from target locale PO items
70+
targetLocales.forEach((targetLocale) => {
71+
paths[targetLocale].forEach((path) => {
72+
let raw = fs.readFileSync(path).toString()
73+
let po = PO.parse(raw)
74+
75+
po.items.filter((item) => !item['obsolete']).forEach((item, index) => {
76+
segments[targetLocale][index].target = item.msgstr[0]
77+
})
78+
})
79+
})
80+
81+
let request = {
82+
"client": "lingui",
83+
"version": require('@lingui/core/package.json').version,
84+
"source_language": sourceLocale,
85+
"target_languages": targetLocales,
86+
"segments": segments
87+
}
88+
89+
postTio("init", request, config.service.apiKey, (response) => {
90+
if (response.errors) {
91+
failCallback(response.errors)
92+
}
93+
else {
94+
saveSegmentsToTargetPos(config, paths, response.segments)
95+
successCallback(response.project)
96+
}
97+
}, (error) => {
98+
console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`)
99+
})
100+
}
101+
102+
// Send all source text from PO to Translation.io and create new PO based on received translations
103+
// Cf. https://translation.io/docs/create-library#synchronization
104+
function sync(config, options, successCallback, failCallback) {
105+
const sourceLocale = config.sourceLocale || 'en'
106+
const targetLocales = config.locales.filter((value) => value != sourceLocale)
107+
const paths = poPathsPerLocale(config)
108+
109+
let segments = []
110+
111+
// Create segments with correct source
112+
paths[sourceLocale].forEach((path) => {
113+
let raw = fs.readFileSync(path).toString()
114+
let po = PO.parse(raw)
115+
116+
po.items.filter((item) => !item['obsolete']).forEach((item) => {
117+
let newSegment = createSegmentFromPoItem(item)
118+
119+
segments.push(newSegment)
120+
})
121+
})
122+
123+
let request = {
124+
"client": "lingui",
125+
"version": require('@lingui/core/package.json').version,
126+
"source_language": sourceLocale,
127+
"target_languages": targetLocales,
128+
"segments": segments
129+
}
130+
131+
// Sync and then remove unused segments (not present in the local application) from Translation.io
132+
if (options.clean) {
133+
request['purge'] = true
134+
}
135+
136+
postTio("sync", request, config.service.apiKey, (response) => {
137+
if (response.errors) {
138+
failCallback(response.errors)
139+
}
140+
else {
141+
saveSegmentsToTargetPos(config, paths, response.segments)
142+
successCallback(response.project)
143+
}
144+
}, (error) => {
145+
console.error(`\n----------\nSynchronization with Translation.io failed: ${error}\n----------`)
146+
})
147+
}
148+
149+
function createSegmentFromPoItem(item) {
150+
let itemHasId = item.msgid != item.msgstr[0] && item.msgstr[0].length
151+
152+
let segment = {
153+
type: 'source', // No way to edit text for source language (inside code), so not using "key" here
154+
source: itemHasId ? item.msgstr[0] : item.msgid, // msgstr may be empty if --overwrite is used and no ID is used
155+
context: '',
156+
references: [],
157+
comment: ''
158+
}
159+
160+
if (itemHasId) {
161+
segment.context = item.msgid
162+
}
163+
164+
if (item.references.length) {
165+
segment.references = item.references
166+
}
167+
168+
if (item.extractedComments.length) {
169+
segment.comment = item.extractedComments.join(' | ')
170+
}
171+
172+
return segment
173+
}
174+
175+
function createPoItemFromSegment(segment) {
176+
let item = new PO.Item()
177+
178+
item.msgid = segment.context ? segment.context : segment.source
179+
item.msgstr = [segment.target]
180+
item.references = (segment.references && segment.references.length) ? segment.references : []
181+
item.extractedComments = segment.comment ? segment.comment.split(' | ') : []
182+
183+
return item
184+
}
185+
186+
function saveSegmentsToTargetPos(config, paths, segmentsPerLocale) {
187+
const NAME = "{name}"
188+
const LOCALE = "{locale}"
189+
190+
Object.keys(segmentsPerLocale).forEach((targetLocale) => {
191+
// Remove existing target POs and JS for this target locale
192+
paths[targetLocale].forEach((path) => {
193+
const jsPath = path.replace(/\.po?$/, "") + ".js"
194+
const dirPath = dirname(path)
195+
196+
// Remove PO, JS and empty dir
197+
if (fs.existsSync(path)) { fs.unlinkSync(path) }
198+
if (fs.existsSync(jsPath)) { fs.unlinkSync(jsPath) }
199+
if (fs.existsSync(dirPath) && fs.readdirSync(dirPath).length === 0) { fs.rmdirSync(dirPath) }
200+
})
201+
202+
// Find target path (ignoring {name})
203+
const localePath = "".concat(config.catalogs[0].path.replace(LOCALE, targetLocale).replace(NAME, ''), ".po")
204+
const segments = segmentsPerLocale[targetLocale]
205+
206+
let po = new PO()
207+
po.headers = getCreateHeaders(targetLocale)
208+
209+
let items = []
210+
211+
segments.forEach((segment) => {
212+
let item = createPoItemFromSegment(segment)
213+
items.push(item)
214+
})
215+
216+
// Sort items by messageId
217+
po.items = items.sort((a, b) => {
218+
if (a.msgid < b.msgid) { return -1 }
219+
if (a.msgid > b.msgid) { return 1 }
220+
return 0
221+
})
222+
223+
// Check that localePath directory exists and save PO file
224+
fs.promises.mkdir(dirname(localePath), {recursive: true}).then(() => {
225+
po.save(localePath, (err) => {
226+
if (err) {
227+
console.error('Error while saving target PO files:')
228+
console.error(err)
229+
process.exit(1)
230+
}
231+
})
232+
})
233+
})
234+
}
235+
236+
function poPathsPerLocale(config) {
237+
const NAME = "{name}"
238+
const LOCALE = "{locale}"
239+
const paths = []
240+
241+
config.locales.forEach((locale) => {
242+
paths[locale] = []
243+
244+
config.catalogs.forEach((catalog) => {
245+
const path = "".concat(catalog.path.replace(LOCALE, locale).replace(NAME, "*"), ".po")
246+
247+
// If {name} is present (replaced by *), list all the existing POs
248+
if (path.includes('*')) {
249+
paths[locale] = paths[locale].concat(glob.sync(path))
250+
}
251+
else {
252+
paths[locale].push(path)
253+
}
254+
})
255+
})
256+
257+
return paths
258+
}
259+
260+
function postTio(action, request, apiKey, successCallback, failCallback) {
261+
let jsonRequest = JSON.stringify(request)
262+
263+
let options = {
264+
hostname: 'translation.io',
265+
path: '/api/v1/segments/' + action + '.json?api_key=' + apiKey,
266+
method: 'POST',
267+
headers: {
268+
'Content-Type': 'application/json',
269+
}
270+
}
271+
272+
let req = https.request(options, (res) => {
273+
res.setEncoding('utf8')
274+
275+
let body = ""
276+
277+
res.on('data', (chunk) => {
278+
body = body.concat(chunk)
279+
})
280+
281+
res.on('end', () => {
282+
let response = JSON.parse(body)
283+
successCallback(response)
284+
})
285+
})
286+
287+
req.on('error', (e) => {
288+
failCallback(e)
289+
})
290+
291+
req.write(jsonRequest)
292+
req.end()
293+
}

packages/conf/index.d.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { GeneratorOptions } from "@babel/core";
22

3-
export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
3+
export declare type CatalogFormat = "lingui" | "minimal" | "po" | "csv" | "po-gettext";
44
export type CatalogFormatOptions = {
55
origins?: boolean;
66
lineNumbers?: boolean;
@@ -23,6 +23,11 @@ export type DefaultLocaleObject = {
2323

2424
export declare type FallbackLocales = LocaleObject | DefaultLocaleObject
2525

26+
declare type CatalogService = {
27+
name: string;
28+
apiKey: string;
29+
}
30+
2631
declare type ExtractorType = {
2732
match(filename: string): boolean;
2833
extract(filename: string, targetDir: string, options?: any): void;
@@ -46,6 +51,7 @@ export declare type LinguiConfig = {
4651
rootDir: string;
4752
runtimeConfigModule: [string, string?];
4853
sourceLocale: string;
54+
service: CatalogService;
4955
};
5056
export declare const defaultConfig: LinguiConfig;
5157
export declare function getConfig({ cwd, configPath, skipValidation, }?: {
@@ -63,7 +69,7 @@ export declare const configValidation: {
6369
};
6470
compilerBabelOptions: GeneratorOptions;
6571
catalogs: CatalogConfig[];
66-
compileNamespace: "es" | "ts" | "cjs" | string;
72+
compileNamespace: "es" | "ts" | "cjs" | string;
6773
fallbackLocales: FallbackLocales;
6874
format: CatalogFormat;
6975
formatOptions: CatalogFormatOptions;
@@ -73,6 +79,7 @@ export declare const configValidation: {
7379
rootDir: string;
7480
runtimeConfigModule: [string, string?];
7581
sourceLocale: string;
82+
service: CatalogService;
7683
};
7784
deprecatedConfig: {
7885
fallbackLocale: (config: LinguiConfig & DeprecatedFallbackLanguage) => string;

packages/conf/src/__snapshots__/index.test.ts.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,10 @@ Object {
150150
@lingui/core,
151151
i18n,
152152
],
153+
service: Object {
154+
apiKey: ,
155+
name: ,
156+
},
153157
sourceLocale: ,
154158
}
155159
`;

packages/conf/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export type FallbackLocales = LocaleObject | DefaultLocaleObject | false
3232

3333
type ModuleSource = [string, string?]
3434

35+
type CatalogService = {
36+
name: string
37+
apiKey: string
38+
}
39+
3540
type ExtractorType = {
3641
match(filename: string): boolean
3742
extract(filename: string, targetDir: string, options?: any): void
@@ -53,6 +58,7 @@ export type LinguiConfig = {
5358
rootDir: string
5459
runtimeConfigModule: ModuleSource | { [symbolName: string]: ModuleSource }
5560
sourceLocale: string
61+
service: CatalogService
5662
}
5763

5864
// Enforce posix path delimiters internally
@@ -98,6 +104,7 @@ export const defaultConfig: LinguiConfig = {
98104
rootDir: ".",
99105
runtimeConfigModule: ["@lingui/core", "i18n"],
100106
sourceLocale: "",
107+
service: { name: "", apiKey: "" }
101108
}
102109

103110
function configExists(path) {

0 commit comments

Comments
 (0)