@freckle/i18n-scripts 1.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.
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Checks all locize keys (in the codebase) with known variables against key
4
+ // values in locize ensuring that variables in locize refer to known vars.
5
+ //
6
+ const {promises: fs} = require('fs')
7
+ const p = require('path')
8
+ const i18next = require('i18next')
9
+ const ICU = require('i18next-icu')
10
+ const HttpBackend = require('i18next-http-backend')
11
+ const {IntlMessageFormat} = require('intl-messageformat')
12
+
13
+ const {
14
+ extractTranslationKeysAndVariables,
15
+ KNOWN_VARIABLES_TAG
16
+ } = require('./translation-extraction.js')
17
+ const {getFiles, filterJsFiles, supportedLngs} = require('./helpers.js')
18
+
19
+ const [_nodeBin, _scriptPath, ...projects] = process.argv
20
+
21
+ if (projects.length < 1) {
22
+ console.log('Usage: check-i18n-variables <path>')
23
+ return
24
+ }
25
+
26
+ // TODO: Pass in via ENV?
27
+ const ns = ['school', 'student', 'console', 'classroom', 'common']
28
+
29
+ const translationKeysWithKnownVariablesInProject = async project => {
30
+ const files = await getFiles(project)
31
+ const jsFiles = files.filter(filterJsFiles)
32
+ const results = []
33
+
34
+ jsFiles.forEach(({path}) => {
35
+ extractTranslationKeysAndVariables(path)
36
+ .filter(({tag}) => tag === KNOWN_VARIABLES_TAG)
37
+ .forEach(keyWithVars => results.push(keyWithVars))
38
+ })
39
+
40
+ return results
41
+ }
42
+
43
+ // The AST is undocumented, however, its type definitions are here
44
+ // https://github.com/formatjs/formatjs/blob/82a2e5d07f9a999624ffb6387a58464f9a5f7399/packages/icu-messageformat-parser/types.ts#L122-L131
45
+ const extractVariablesFromICUAst = ast => {
46
+ // Some AST field values are `null`
47
+ if (!ast) {
48
+ return []
49
+ }
50
+
51
+ if (ast.constructor === Array) {
52
+ return ast.flatMap(extractVariablesFromICUAst)
53
+ }
54
+ if (ast.constructor === Object) {
55
+ const subVariables = Object.values(ast).flatMap(extractVariablesFromICUAst)
56
+ // `type`s that aren't 0 (string literals) are variables when `.value` exists
57
+ const hasVariable = ast.type && typeof ast.type === 'number' && ast.type !== 0 && ast.value
58
+
59
+ return (hasVariable ? [ast.value] : []).concat(subVariables)
60
+ }
61
+
62
+ return []
63
+ }
64
+
65
+ const main = async () => {
66
+ const t = await i18next
67
+ .use(ICU)
68
+ .use(HttpBackend)
69
+ .init({
70
+ supportedLngs,
71
+ allowMultiLoading: true,
72
+ keySeparator: false,
73
+ ns,
74
+ backend: {
75
+ loadPath: 'https://translations.freckle.com/latest/{{lng}}/{{ns}}'
76
+ }
77
+ })
78
+
79
+ let anyErrors = false
80
+
81
+ // Project key extraction is not cheap so cache per project
82
+ const keysPerProject = {}
83
+
84
+ await Promise.all(
85
+ projects.map(
86
+ async project =>
87
+ (keysPerProject[project] = await translationKeysWithKnownVariablesInProject(project))
88
+ )
89
+ )
90
+
91
+ for (const lang of supportedLngs) {
92
+ await i18next.changeLanguage(lang)
93
+ const translations = i18next.getDataByLanguage(lang)
94
+
95
+ for (const [project, keys] of Object.entries(keysPerProject)) {
96
+ for (const {i18nKey, variables: codeVariables} of keys) {
97
+ const keyValue =
98
+ (translations[project] && translations[project][i18nKey]) || translations.common[i18nKey]
99
+
100
+ try {
101
+ if (keyValue) {
102
+ const locizeVariables = extractVariablesFromICUAst(
103
+ new IntlMessageFormat(keyValue, lang, {}, {ignoreTag: true}).getAst()
104
+ )
105
+
106
+ for (const locizeVariable of locizeVariables) {
107
+ if (!codeVariables.includes(locizeVariable)) {
108
+ anyErrors = true
109
+ console.error(
110
+ `${project}:${lang}:${i18nKey} does not pass the variable ${locizeVariable}`
111
+ )
112
+ }
113
+ }
114
+ }
115
+ } catch (e) {
116
+ // We have another script that checks that `IntlMessageFormat` can
117
+ // successfully parse the ICU text so let's not fail for that reason.
118
+ }
119
+ }
120
+ }
121
+ }
122
+
123
+ if (anyErrors) {
124
+ console.error(
125
+ 'The above keys have variables not passed by the code and need to be fixed in Locize.'
126
+ )
127
+ process.exit(1)
128
+ } else {
129
+ process.exit(0)
130
+ }
131
+ }
132
+
133
+ main()
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {spawn} = require('node:child_process')
4
+ const path = require('path')
5
+
6
+ const [_nodeBin, scriptPath, ...args] = process.argv
7
+
8
+ const proc = spawn(`${path.dirname(scriptPath)}/check-i18n-variables`, args)
9
+
10
+ proc.stdout.on('data', data => {
11
+ process.stdout.write(data)
12
+ })
13
+
14
+ proc.stderr.on('data', data => {
15
+ process.stderr.write(data)
16
+ })
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // @flow
3
+ //
4
+ // Checks all keys in Locize to make sure that they can be parsed by the `i18next` `t` function
5
+ //
6
+ const i18next = require('i18next')
7
+ const ICU = require('i18next-icu')
8
+ const HttpBackend = require('i18next-http-backend')
9
+ const {supportedLngs} = require('./helpers.js')
10
+
11
+ const VALID_NAMESPACES = ['school', 'student', 'console', 'classroom', 'common']
12
+
13
+ const ns = process.argv.slice(2)
14
+
15
+ for (const arg of ns) {
16
+ if (!VALID_NAMESPACES.some(ns => ns === arg)) {
17
+ throw new Error(`Invalid arguments: ${arg} is not a namespace`)
18
+ }
19
+ }
20
+
21
+
22
+ const main = async () => {
23
+ const t = await i18next
24
+ .use(ICU)
25
+ .use(HttpBackend)
26
+ .init({
27
+ supportedLngs,
28
+ allowMultiLoading: true,
29
+ keySeparator: false,
30
+ ns,
31
+ backend: {
32
+ loadPath: 'https://translations.freckle.com/latest/{{lng}}/{{ns}}'
33
+ }
34
+ })
35
+
36
+ let anyErrors = false
37
+
38
+ for (const l of supportedLngs) {
39
+ await i18next.changeLanguage(l)
40
+ for (const n of ns) {
41
+ for (const key of Object.keys(i18next.getDataByLanguage(l)[n])) {
42
+ try {
43
+ t(`${n}:${key}`)
44
+ } catch (error) {
45
+ if (!isInterpolationError(error)) {
46
+ anyErrors = true
47
+ console.error(`${n}:${l}:${key}`)
48
+ }
49
+ }
50
+ }
51
+ }
52
+ }
53
+
54
+ if (anyErrors) {
55
+ console.error('The above keys cannot be parsed as ICU and need to be fixed in Locize.')
56
+ process.exit(1)
57
+ } else {
58
+ process.exit(0)
59
+ }
60
+ }
61
+
62
+ const isInterpolationError = error => /^The intl string context variable/.test(error.message)
63
+
64
+ main()
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {spawn} = require('node:child_process')
4
+ const path = require('path')
5
+
6
+ const [_nodeBin, scriptPath, ...args] = process.argv
7
+
8
+ const proc = spawn(`${path.dirname(scriptPath)}/check-i18next-icu-parser`, args)
9
+
10
+ proc.stdout.on('data', data => {
11
+ process.stdout.write(data)
12
+ })
13
+
14
+ proc.stderr.on('data', data => {
15
+ process.stderr.write(data)
16
+ })
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ set -eu
3
+
4
+ script_dir="$(cd "$(dirname "$0")" && pwd)"
5
+
6
+ translation_keys_in_code() {
7
+ local project_path=$1
8
+
9
+ git ls-files "$project_path" |
10
+ grep -E '\.(js|ts|tsx)$' |
11
+ grep -v '\.min\.js$' |
12
+ "$script_dir/translation-keys"
13
+ }
14
+
15
+ translation_keys_in_locize() {
16
+ local namespace=$1
17
+
18
+ curl --silent --fail --show-error \
19
+ "https://translations.freckle.com/latest/en/$namespace" |
20
+ jq --raw-output 'keys[]'
21
+ }
22
+
23
+ empty_translation_keys_in_locize() {
24
+ local namespace=$1
25
+
26
+ curl --silent --fail --show-error \
27
+ "https://translations.freckle.com/latest/en/$namespace" |
28
+ jq --raw-output '[to_entries | .[] | select(.key | test("^[A-Z_]+$")) | select(.key == .value)] | .[] | .key' |
29
+ grep -xv 'OK' | grep -xv 'ELA' # Whitelist some common translation keys
30
+ }
31
+
32
+ translation_keys_only_in_code() {
33
+ local project_path=$1 namespace=$2
34
+
35
+ comm -23 \
36
+ <(translation_keys_in_code "$project_path" | sort -u) \
37
+ <(translation_keys_in_locize "$namespace" | sort -u)
38
+ }
39
+
40
+ empty_translation_keys_used_in_locize() {
41
+ local project_path=$1 namespace=$2
42
+
43
+ comm -12 \
44
+ <(translation_keys_in_code "$project_path" | sort -u) \
45
+ <(empty_translation_keys_in_locize "$namespace" | sort -u)
46
+ }
47
+
48
+ if (($# < 1)); then
49
+ echo "Usage: check-missing-translations namespace [project/path]" >&2
50
+ exit 64
51
+ fi
52
+
53
+ namespace=${1}
54
+ project_path=${2-.}
55
+
56
+ tmp=$(mktemp)
57
+ trap 'rm -f -- "$tmp"' EXIT
58
+
59
+ # Check which keys are present in the frontend but not Locize
60
+ translation_keys_only_in_code "$project_path" "$namespace" | sed 's/$/ not present/' | tee "$tmp"
61
+
62
+ # Check which keys are present in both the frontend and Locize but are untranslated
63
+ empty_translation_keys_used_in_locize "$project_path" "$namespace" | sed 's/$/ not translated/' | tee -a "$tmp"
64
+
65
+ if [ -s "$tmp" ]; then
66
+ echo
67
+ echo "The above keys either need to be added to Locize or translated" >&2
68
+ exit 1
69
+ fi
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+
3
+ const {spawn} = require('node:child_process')
4
+ const path = require('path')
5
+
6
+ const [_nodeBin, scriptPath, ...args] = process.argv
7
+
8
+ const proc = spawn(`${path.dirname(scriptPath)}/check-missing-translations`, args)
9
+
10
+ proc.stdout.on('data', data => {
11
+ process.stdout.write(data)
12
+ })
13
+
14
+ proc.stderr.on('data', data => {
15
+ process.stderr.write(data)
16
+ })
package/src/helpers.js ADDED
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ const {promises: fs} = require('fs')
3
+ const p = require('path')
4
+
5
+ // Modified from https://dev.to/leonard/get-files-recursive-with-the-node-js-file-system-fs-2n7o
6
+ async function getFiles(path) {
7
+ const allEntries = await fs.readdir(path, {withFileTypes: true})
8
+
9
+ const entries = allEntries.filter(file => file.name !== 'node_modules' && file.name !== '.cache')
10
+
11
+ const files = entries
12
+ .filter(file => !file.isDirectory())
13
+ .map(file => ({...file, path: p.join(path, file.name)}))
14
+
15
+ const folders = entries.filter(folder => folder.isDirectory())
16
+
17
+ for (const folder of folders) {
18
+ files.push(...(await getFiles(p.join(path, folder.name))))
19
+ }
20
+
21
+ return files
22
+ }
23
+
24
+ module.exports.getFiles = getFiles
25
+
26
+ const filterJsFiles = f =>
27
+ (f.name.endsWith('.js') || f.name.endsWith('.ts') || f.name.endsWith('.tsx')) &&
28
+ !f.name.endsWith('min.js')
29
+
30
+ module.exports.filterJsFiles = filterJsFiles
31
+
32
+ const supportedLngs = ['es', 'en', 'en-GB']
33
+
34
+ module.exports.supportedLngs = supportedLngs
@@ -0,0 +1,141 @@
1
+ const fs = require('fs')
2
+ const parser = require('flow-parser')
3
+ const walk = require('esprima-walk')
4
+
5
+ const isIdentifierT = node => node && node.type === 'Identifier' && node.name === 't'
6
+
7
+ const isTransTag = node =>
8
+ node.type === 'JSXElement' &&
9
+ node.openingElement.type === 'JSXOpeningElement' &&
10
+ node.openingElement.name.name === 'Trans'
11
+
12
+ const isJsxAttribute = attr => node =>
13
+ node && node.type === 'JSXAttribute' && node.name.name === attr
14
+
15
+ const naiveLooksLikeApplication = (content, node, previousNode) =>
16
+ content.slice(previousNode.range[1], node.range[0]).trim() === '('
17
+
18
+ const looksLikeTApplication = (content, node, previousNode) =>
19
+ node.type === 'Literal' &&
20
+ isIdentifierT(previousNode) &&
21
+ naiveLooksLikeApplication(content, node, previousNode)
22
+
23
+ const isObjectWithSimpleProperties = node =>
24
+ node.type === 'ObjectExpression' &&
25
+ node.properties.every(p => p.type === 'Property' && p.key.type === 'Identifier')
26
+
27
+ const KNOWN_VARIABLES_TAG = 'i18n-key-with-known-variables'
28
+ const UNKNOWN_VARIABLES_TAG = 'i18n-key-with-unknown-variables'
29
+
30
+ const knownVariables = (source, i18nKey, objectWithSimpleProperties) => ({
31
+ tag: KNOWN_VARIABLES_TAG,
32
+ source,
33
+ i18nKey,
34
+ variables: objectWithSimpleProperties.properties.map(p => p.key.name)
35
+ })
36
+
37
+ const knownNoVariables = (source, i18nKey) => ({
38
+ tag: KNOWN_VARIABLES_TAG,
39
+ source,
40
+ i18nKey,
41
+ variables: []
42
+ })
43
+
44
+ const unknownVariables = (source, i18nKey) => ({
45
+ tag: UNKNOWN_VARIABLES_TAG,
46
+ source,
47
+ i18nKey
48
+ })
49
+
50
+ module.exports.KNOWN_VARIABLES_TAG = KNOWN_VARIABLES_TAG
51
+ module.exports.UNKNOWN_VARIABLES_TAG = UNKNOWN_VARIABLES_TAG
52
+
53
+ // Attempt to find all translations keys and their provided variables in use in JS files.
54
+ //
55
+ // Methodology:
56
+ //
57
+ // Locate any
58
+ // - Literal nodes that immediately follow an Identifier node named t, and
59
+ // output their value, or
60
+ // - Trans component instances and output their i18nKey values
61
+ //
62
+ // If possible, output any variables (if possible) used by the keys. This is
63
+ // done with no further analysis than looking for a variable object and
64
+ // - if one's not found, report no variables
65
+ // - if one's found and is a simple object, report its properties
66
+ // - if one's found and is a more complex object, report that the variables
67
+ // are not known
68
+ //
69
+ // Yields keys with variables as encountered without sorting or de-duplication.
70
+ module.exports.extractTranslationKeysAndVariables = path => {
71
+ const content = fs.readFileSync(path, {encoding: 'utf8'})
72
+ const ast = parser.parse(content)
73
+
74
+ const results = []
75
+ let previousNode = null
76
+
77
+ // Possible `tag` values are
78
+ // - 'finding-i18n-key' which means we're trying to find an i18n key
79
+ // - 'found-t-application' which means we found a key like `t('KEY'` and we
80
+ // ought to check the next token to see if it's a simple object literal
81
+ let state = {tag: 'finding-i18n-key'}
82
+
83
+ walk(ast, node => {
84
+ switch (state.tag) {
85
+ case 'finding-i18n-key': {
86
+ if (looksLikeTApplication(content, node, previousNode)) {
87
+ state = {tag: 'found-t-application', key: node.value}
88
+ } else if (isTransTag(node)) {
89
+ const i18nKey = node.openingElement.attributes.find(isJsxAttribute('i18nKey'))
90
+ const values = node.openingElement.attributes.find(isJsxAttribute('values'))
91
+ const hasCount = node.openingElement.attributes.some(isJsxAttribute('count'))
92
+
93
+ // `Trans` keys can have a `count` property which ends up being a
94
+ // variable. If we have known variables we've gotta account for it...
95
+ const accountForCount = i18nKeyVithVariables => ({
96
+ ...i18nKeyVithVariables,
97
+ variables: i18nKeyVithVariables.variables.concat(hasCount ? ['count'] : [])
98
+ })
99
+
100
+ if (
101
+ i18nKey.value.value &&
102
+ values &&
103
+ values.value.type === 'JSXExpressionContainer' &&
104
+ isObjectWithSimpleProperties(values.value.expression)
105
+ ) {
106
+ results.push(
107
+ accountForCount(
108
+ knownVariables('trans-component', i18nKey.value.value, values.value.expression)
109
+ )
110
+ )
111
+ } else if (i18nKey.value.value && !values) {
112
+ results.push(accountForCount(knownNoVariables('trans-component', i18nKey.value.value)))
113
+ } else if (i18nKey.value.value) {
114
+ results.push(unknownVariables('trans-component', i18nKey.value.value))
115
+ }
116
+ }
117
+ break
118
+ }
119
+ case 'found-t-application': {
120
+ if (isObjectWithSimpleProperties(node)) {
121
+ results.push(knownVariables('t-application', state.key, node))
122
+ } else if (typeof node === 'number') {
123
+ // No object after the key means no variables. For some reason this is an integer
124
+ results.push(knownNoVariables('t-application', state.key))
125
+ } else {
126
+ results.push(unknownVariables('t-application', state.key))
127
+ }
128
+ state = {tag: 'finding-i18n-key'}
129
+ break
130
+ }
131
+ case 'found-trans-component': {
132
+ state = {tag: 'finding-i18n-key'}
133
+ break
134
+ }
135
+ }
136
+
137
+ previousNode = node
138
+ })
139
+
140
+ return results
141
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ //
3
+ // Attempt to find all translations keys in use in JS files passed on stdin.
4
+ //
5
+ // Usage:
6
+ //
7
+ // git ls-files |
8
+ // grep '\.js$' |
9
+ // grep -v '\.min\.js$' |
10
+ // ./scripts/translation-keys
11
+ //
12
+ // We stream out the keys as seen. We do no sorting or de-duplication.
13
+ //
14
+ const {extractTranslationKeysAndVariables} = require('./translation-extraction.js')
15
+ const readline = require('readline')
16
+
17
+ const interface = readline.createInterface({
18
+ input: process.stdin,
19
+ output: process.stdout,
20
+ terminal: false
21
+ })
22
+
23
+ interface.on('line', path => {
24
+ extractTranslationKeysAndVariables(path).forEach(({i18nKey}) =>
25
+ console.log(i18nKey)
26
+ )
27
+ })