@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.
package/.yarnrc.yml ADDED
@@ -0,0 +1,3 @@
1
+ nodeLinker: node-modules
2
+
3
+ yarnPath: .yarn/releases/yarn-3.4.1.cjs
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2022 Renaissance Learning Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # `@freckle/i18n-scripts`
2
+
3
+ Scripts for i18n validation.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ yarn add @freckle/i18n-scripts
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ TODO
14
+
15
+ ## Versioning and release process
16
+
17
+ See [RELEASE.md](./RELEASE.md).
18
+
19
+ ---
20
+
21
+ [LICENSE](./LICENSE)
package/RELEASE.md ADDED
@@ -0,0 +1,34 @@
1
+ # Releases
2
+
3
+ ## Versioning
4
+
5
+ Versioned tags will exist, such as `v1.0.0` and `v2.1.1`. Branches will exist
6
+ for each major version, such as `v1` or `v2` and contain the newest version in
7
+ that series.
8
+
9
+ ## Release process
10
+
11
+ Given a latest version of v1.0.1,
12
+
13
+ Is this a new major version?
14
+
15
+ If yes,
16
+
17
+ ```console
18
+ git checkout main
19
+ git pull
20
+ git checkout -b v2
21
+ git tag -s -m v2.0.0 v2.0.0
22
+ git push --follow-tags
23
+ ```
24
+
25
+ Otherwise,
26
+
27
+ ```console
28
+ git checkout main
29
+ git pull
30
+ git checkout v1
31
+ git merge --ff-only -
32
+ git tag -s -m v1.0.2 v1.0.2 # or v1.1.0
33
+ git push --follow-tags
34
+ ```
@@ -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
+ })
@@ -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
+ })
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@freckle/i18n-scripts",
3
+ "version": "1.0.0",
4
+ "description": "Scripts for i18n",
5
+ "author": "Freckle",
6
+ "license": "MIT",
7
+ "bin": {
8
+ "check-i18n-variables": "./dist/check-i18n-variables.js",
9
+ "check-i18next-icu-parser": "./dist/check-i18next-icu-parser.js",
10
+ "check-missing-translations": "./dist/check-missing-translations.js"
11
+ },
12
+ "main": "./dist/index.js",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/freckle/i18n-scripts-js.git"
16
+ },
17
+ "scripts": {
18
+ "build": "rm -r dist && cp -r src dist"
19
+ },
20
+ "dependencies": {
21
+ "esprima-walk": "0.1.0",
22
+ "flow-parser": "0.140.0",
23
+ "i18next": "19.1.0",
24
+ "i18next-http-backend": "^2.1.1",
25
+ "i18next-icu": "^2.1.0",
26
+ "intl-messageformat": "^10.3.1"
27
+ },
28
+ "packageManager": "yarn@3.4.1"
29
+ }