-
Notifications
You must be signed in to change notification settings - Fork 0
/
utils.js
319 lines (276 loc) · 12.4 KB
/
utils.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
import archy from 'archy'
import chalk from 'chalk'
import {decode} from 'html-entities'
import fs from 'fs-extra'
import * as deepl from 'deepl-node'
import path from 'path'
import sass from 'sass'
import tildify from 'tildify'
export const __dirname = path.dirname(new URL(import.meta.url).pathname)
const format = {
selected: (options, selected) => {
let styledOptions = options.map((option) => {
if (option === selected) return chalk.blue.underline(option)
else return chalk.grey(option)
})
return `${chalk.grey('[')}${styledOptions.join(chalk.grey('|'))}${chalk.grey(']')}`
},
}
const log_prefixes = {
error: chalk.bold.red('[dotbuild]'.padEnd(16, ' ')),
ok: chalk.bold.green('[dotbuild]'.padEnd(16, ' ')),
warning: chalk.bold.yellow('[dotbuild]'.padEnd(16, ' ')),
}
const log = function(level, message) {
// eslint-disable-next-line no-console
console.log(`${log_prefixes[level]}${message}`)
}
export const buildConfig = async function(cli, argv) {
const tree = {
label: 'Config:',
nodes: [
{
label: chalk.bold.blue('Directories'),
nodes: Object.entries(cli.settings.dir).map(([k, dir]) => {
return {label: `${k.padEnd(10, ' ')} ${tildify(dir)}`}
}),
},
{
label: chalk.bold.blue('Build Flags'),
nodes: [
{label: `${'buildId'.padEnd(10, ' ')} ${cli.settings.buildId}`},
{label: `${'minify'.padEnd(10, ' ')} ${cli.settings.minify}`},
{label: `${'sourceMap'.padEnd(10, ' ')} ${cli.settings.sourceMap}`},
{label: `${'package'.padEnd(10, ' ')} ${format.selected(['backend', 'client'], cli.settings.package)}`},
{label: `${'version'.padEnd(10, ' ')} ${cli.settings.version}`},
],
},
],
}
if (argv._.includes('dev')) {
cli.log(`\nDevserver: ${chalk.grey(`${cli.settings.dev.host}:${cli.settings.dev.port}`)}`)
}
cli.log('\r')
archy(tree).split('\r').forEach((line) => cli.log(line))
}
export function flattenEnv(obj, parent, res = {}) {
for (const key of Object.keys(obj)) {
const propName = (parent ? parent + '_' + key : key).toUpperCase()
if (typeof obj[key] === 'object') {
flattenEnv(obj[key], propName, res)
} else {
res[`PYR_${propName}`] = obj[key]
}
}
return res
}
export function keyMod(reference, apply, refPath) {
if (!refPath) {
refPath = []
}
for (const key of Object.keys(reference)) {
if (typeof reference[key] === 'object') {
refPath.push(key)
keyMod(reference[key], apply, refPath)
} else {
apply(reference, key, refPath)
}
}
refPath.pop()
}
export function keyPath(obj, refPath, create = false) {
if (!Array.isArray(refPath)) throw new Error('refPath must be an array')
if (!refPath.length) return obj
const _refPath = [...refPath]
let _obj = obj
while (_refPath.length) {
const key = _refPath.shift()
if (typeof _obj === 'object' && key in _obj) {
_obj = _obj[key]
} else if (create) {
_obj[key] = {}
_obj = _obj[key]
}
}
return _obj
}
export function Scss(settings) {
return async function(options) {
const result = sass.renderSync({
data: options.data,
file: options.file,
includePaths: [
settings.dir.code,
settings.dir.components,
],
outFile: options.outFile,
outputStyle: options.minify ? 'compressed' : 'expanded',
sourceMap: options.sourceMap,
sourceMapContents: true,
})
let styles = result.css.toString()
if (result.map) {
await fs.writeFile(`${options.outFile}.map`, result.map, 'utf8')
}
await fs.writeFile(options.outFile, styles, 'utf8')
return styles
}
}
export function sortNestedObjectKeys(obj) {
if (typeof obj !== 'object' || obj === null) {
return obj
}
const sortedKeys = Object.keys(obj).sort()
const sortedObj = {}
sortedKeys.forEach((key) => {
const value = obj[key]
sortedObj[key] = sortNestedObjectKeys(value)
})
return sortedObj
}
export class Translator {
constructor(settings) {
this.settings = settings
this.deeplKeyException = new Error('Deepl translator key required for auto-translate (process.env.DOTBUILD_TRANSLATOR_KEY)')
}
collectStats(task, stats) {
const _stats = {costs: {chars: 0, keys: 0}, total: {chars: 0, keys: 0}}
for (const stat of stats) {
_stats.costs.chars += stat.costs.chars
_stats.costs.chars += stat.costs.keys
_stats.total.chars += stat.total.chars
_stats.total.keys += stat.total.keys
}
const costs = `(update: ${_stats.costs.keys}/${_stats.costs.chars})`
const total = `(total: ${_stats.total.keys} keys/${_stats.total.chars} chars)`
task.log(`${task.prefix.ok}translate i18n entries: ${costs} ${total}`)
}
async initDeepl() {
log('ok', 'Initializing Deepl...')
const authKey = process.env.DOTBUILD_TRANSLATOR_KEY
if (!authKey) throw new Error('Deepl translator key required for auto-translate (process.env.DOTBUILD_TRANSLATOR_KEY)')
this.deepl = new deepl.Translator(authKey)
this.glossaries = await this.deepl.listGlossaries()
const usage = await this.deepl.getUsage()
if (usage.anyLimitReached()) {
log('error', 'Deepl translation limit exceeded')
}
if (usage.character) {
const percentage = (usage.character.count / usage.character.limit) * 100
log('ok', `Deepl usage: ${usage.character.count} of ${usage.character.limit} characters (${percentage.toFixed(2)}%)`)
}
if (usage.document) {
log('ok', `Deepl usage: ${usage.document.count} of ${usage.document.limit} documents`)
}
}
async translate(task, targetLanguage, overwrite = false) {
let sourcePath, targetPath, targetI18n
const actions = {remove: [], update: []}
sourcePath = path.join(this.settings.dir.i18n, 'src.json')
targetPath = path.join(this.settings.dir.i18n, `${targetLanguage}.json`)
const sourceI18n = JSON.parse(await fs.readFile(sourcePath, 'utf8'))
const targetExists = await fs.pathExists(targetPath)
if (targetExists && !overwrite) {
targetI18n = JSON.parse(await fs.readFile(targetPath, 'utf8'))
keyMod(targetI18n, (targetRef, key, refPath) => {
const sourceRef = keyPath(sourceI18n, refPath)
// The key in the target i18n does not exist in the source (e.g. obsolete)
if (!sourceRef[key]) {
actions.remove.push([[...refPath], key])
} else if (typeof sourceRef[key] !== typeof targetRef[key]) {
// The value in the target i18n may be a string, where the source is an object
// or viceversa. Mark for deletion, so it can be retranslated.
actions.remove.push([[...refPath], key])
}
})
} else {
// Use a copy of the en i18n scheme as blueprint for the new scheme.
targetI18n = JSON.parse(JSON.stringify(sourceI18n))
}
const placeholderRegex = /{{[\w]*}}/g
// Show a rough estimate of deepl translation costs...
const stats = {total: {chars: 0, keys: 0}, costs: {chars: 0, keys: 0}}
keyMod(sourceI18n, (sourceRef, key, refPath) => {
const targetRef = keyPath(targetI18n, refPath)
const cacheRef = keyPath(this.cacheI18n, refPath)
stats.total.keys += 1
// Use xml tags to indicate placeholders for deepl.
const preppedSource = sourceRef[key].replaceAll(placeholderRegex, (res) => {
return res.replace('{{', '<x>').replace('}}', '</x>')
})
stats.total.chars += preppedSource.length
if (overwrite || !targetRef || !targetRef[key] || cacheRef[key] !== sourceRef[key]) {
stats.costs.chars += preppedSource.length
stats.costs.keys += 1
actions.update.push([[...refPath], key, preppedSource])
}
})
for (const removeAction of actions.remove) {
task.log(`${task.prefix.warning} [${targetLanguage}] remove obsolete key: ${removeAction[0].join('.')}.${removeAction[1]}`)
const targetRef = keyPath(targetI18n, removeAction[0])
delete targetRef[removeAction[1]]
}
if (actions.update.length) {
// Keys that need translation; from here on we require Deepl.
if (!this.deepl) throw this.deeplKeyException
const glossary = this.glossaries.find((i) => i.name === `bitstillery_${targetLanguage}`)
const deeplOptions = {
formality: 'prefer_less',
// The <x> tag is used to to label {{placeholders}} as untranslatable for Deepl. They
// are replaced back with the correct i18n format after translation. The <i> tag
// is just used ignored and stripped.
ignoreTags: ['i', 'x'],
tagHandling: 'xml',
}
if (glossary) {
deeplOptions.glossary = glossary
}
let res = await this.deepl.translateText(actions.update.map((i) => i[2]), 'en', targetLanguage, deeplOptions)
const ignoreXTagRegex = /<x>[\w]*<\/x>/g
const ignoreITagRegex = /<i>[\w]*<\/i>/g
for (const [i, translated] of res.entries()) {
// The results come back in the same order as they were submitted.
// Restore the xml placeholders to the i18n format being use.
const transformedText = translated.text
.replaceAll(ignoreXTagRegex, (res) => res.replace('<x>', '{{').replace('</x>', '}}'))
.replaceAll(ignoreITagRegex, (res) => res.replace('<i>', '').replace('</i>', ''))
const targetRef = keyPath(targetI18n, actions.update[i][0], true)
// Deepl escapes html tags; e.g. < < > > We don't want to ignore
// those, because its content must be translated as well. Instead,
// decode these special html escape characters.
const decodedText = decode(transformedText)
task.log(`${task.prefix.ok}${actions.update[i][0].join('.')} => ${decodedText} (${targetLanguage})`)
targetRef[actions.update[i][1]] = decodedText
}
}
if (actions.update.length || actions.remove.length) {
await fs.writeFile(targetPath, JSON.stringify(sortNestedObjectKeys(targetI18n), null, 4))
}
return stats
}
async updateCache() {
// Track individual changes during development mode.
this.cacheI18n = JSON.parse(await fs.readFile(path.join(this.settings.dir.code, 'i18n', 'src.json'), 'utf8'))
}
async updateGlossaries(task) {
if (!this.deepl) throw this.deeplKeyException
for (const glossary of this.glossaries) {
task.log(`${task.prefix.ok}remove stale glossary: ${glossary.glossaryId}`)
await this.deepl.deleteGlossary(glossary)
}
const globPattern = `${path.join(this.settings.dir.code, 'i18n', 'glossaries', '*.json')}`
const files = await glob(globPattern)
for (const filename of files) {
const language = path.basename(filename).replace('.json', '')
const entries = JSON.parse((await fs.readFile(filename)).toString('utf8'))
// Empty glossaries are not allowed in Deepl.
if (Object.keys(entries).length === 0) continue
const glossaryEntries = new deepl.GlossaryEntries({entries})
const glossaryName = `bitstillery_${language}`
task.log(`${task.prefix.ok}create glossary ${glossaryName}`)
await this.deepl.createGlossary(glossaryName, 'en', language, glossaryEntries)
}
this.glossaries = await this.deepl.listGlossaries()
task.log(`${task.prefix.ok}reloaded glossaries: ${this.glossaries.length}`)
}
}