@freckle/i18n-scripts 1.1.23 → 3.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.
@@ -1,16 +1,128 @@
1
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
+ import HttpBackend from 'i18next-http-backend'
7
+ import ICU from 'i18next-icu'
8
+ import i18next from 'i18next'
9
+ import {IntlMessageFormat} from 'intl-messageformat'
2
10
 
3
- const {spawn} = require('node:child_process')
4
- const path = require('path')
11
+ import {getFiles, filterJsFiles, supportedLngs} from './helpers.js'
12
+ import {extractTranslationKeysAndVariables, KNOWN_VARIABLES_TAG} from './translation-extraction.js'
5
13
 
6
- const [_nodeBin, scriptPath, ...args] = process.argv
14
+ const [_nodeBin, _scriptPath, ...projects] = process.argv
7
15
 
8
- const proc = spawn(`${path.dirname(scriptPath)}/check-i18n-variables`, args)
16
+ if (projects.length < 1) {
17
+ console.error('Usage: check-i18n-variables <path>')
18
+ process.exit(64)
19
+ }
9
20
 
10
- proc.stdout.on('data', data => {
11
- process.stdout.write(data)
12
- })
21
+ // TODO: Pass in via ENV?
22
+ const ns = ['school', 'student', 'console', 'classroom', 'common']
13
23
 
14
- proc.stderr.on('data', data => {
15
- process.stderr.write(data)
16
- })
24
+ const translationKeysWithKnownVariablesInProject = async project => {
25
+ const files = await getFiles(project)
26
+ const jsFiles = files.filter(filterJsFiles)
27
+ const results = []
28
+
29
+ jsFiles.forEach(({path}) => {
30
+ extractTranslationKeysAndVariables(path)
31
+ .filter(({tag}) => tag === KNOWN_VARIABLES_TAG)
32
+ .forEach(keyWithVars => results.push(keyWithVars))
33
+ })
34
+
35
+ return results
36
+ }
37
+
38
+ // The AST is undocumented, however, its type definitions are here
39
+ // https://github.com/formatjs/formatjs/blob/82a2e5d07f9a999624ffb6387a58464f9a5f7399/packages/icu-messageformat-parser/types.ts#L122-L131
40
+ const extractVariablesFromICUAst = ast => {
41
+ // Some AST field values are `null`
42
+ if (!ast) {
43
+ return []
44
+ }
45
+
46
+ if (ast.constructor === Array) {
47
+ return ast.flatMap(extractVariablesFromICUAst)
48
+ }
49
+ if (ast.constructor === Object) {
50
+ const subVariables = Object.values(ast).flatMap(extractVariablesFromICUAst)
51
+ // `type`s that aren't 0 (string literals) are variables when `.value` exists
52
+ const hasVariable = ast.type && typeof ast.type === 'number' && ast.type !== 0 && ast.value
53
+
54
+ return (hasVariable ? [ast.value] : []).concat(subVariables)
55
+ }
56
+
57
+ return []
58
+ }
59
+
60
+ const main = async () => {
61
+ await i18next
62
+ .use(ICU)
63
+ .use(HttpBackend)
64
+ .init({
65
+ supportedLngs,
66
+ allowMultiLoading: true,
67
+ keySeparator: false,
68
+ ns,
69
+ backend: {
70
+ loadPath: 'https://translations.freckle.com/latest/{{lng}}/{{ns}}'
71
+ }
72
+ })
73
+
74
+ let anyErrors = false
75
+
76
+ // Project key extraction is not cheap so cache per project
77
+ const keysPerProject = {}
78
+
79
+ await Promise.all(
80
+ projects.map(
81
+ async project =>
82
+ (keysPerProject[project] = await translationKeysWithKnownVariablesInProject(project))
83
+ )
84
+ )
85
+
86
+ for (const lang of supportedLngs) {
87
+ await i18next.changeLanguage(lang)
88
+ const translations = i18next.getDataByLanguage(lang)
89
+
90
+ for (const [project, keys] of Object.entries(keysPerProject)) {
91
+ for (const {i18nKey, variables: codeVariables} of keys) {
92
+ const keyValue =
93
+ (translations[project] && translations[project][i18nKey]) || translations.common[i18nKey]
94
+
95
+ try {
96
+ if (keyValue) {
97
+ const locizeVariables = extractVariablesFromICUAst(
98
+ new IntlMessageFormat(keyValue, lang, {}, {ignoreTag: true}).getAst()
99
+ )
100
+
101
+ for (const locizeVariable of locizeVariables) {
102
+ if (!codeVariables.includes(locizeVariable)) {
103
+ anyErrors = true
104
+ console.error(
105
+ `${project}:${lang}:${i18nKey} does not pass the variable ${locizeVariable}`
106
+ )
107
+ }
108
+ }
109
+ }
110
+ } catch {
111
+ // We have another script that checks that `IntlMessageFormat` can
112
+ // successfully parse the ICU text so let's not fail for that reason.
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ if (anyErrors) {
119
+ console.error(
120
+ 'The above keys have variables not passed by the code and need to be fixed in Locize.'
121
+ )
122
+ process.exit(1)
123
+ } else {
124
+ process.exit(0)
125
+ }
126
+ }
127
+
128
+ main()
@@ -1,16 +1,63 @@
1
1
  #!/usr/bin/env node
2
+ //
3
+ // Checks all keys in Locize to make sure that they can be parsed by the `i18next` `t` function
4
+ //
5
+ import HttpBackend from 'i18next-http-backend'
6
+ import ICU from 'i18next-icu'
7
+ import i18next from 'i18next'
2
8
 
3
- const {spawn} = require('node:child_process')
4
- const path = require('path')
9
+ import {supportedLngs} from './helpers.js'
5
10
 
6
- const [_nodeBin, scriptPath, ...args] = process.argv
11
+ const VALID_NAMESPACES = ['school', 'student', 'console', 'classroom', 'common']
7
12
 
8
- const proc = spawn(`${path.dirname(scriptPath)}/check-i18next-icu-parser`, args)
13
+ const ns = process.argv.slice(2)
9
14
 
10
- proc.stdout.on('data', data => {
11
- process.stdout.write(data)
12
- })
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
+ }
13
20
 
14
- proc.stderr.on('data', data => {
15
- process.stderr.write(data)
16
- })
21
+ const main = async () => {
22
+ const t = await i18next
23
+ .use(ICU)
24
+ .use(HttpBackend)
25
+ .init({
26
+ supportedLngs,
27
+ allowMultiLoading: true,
28
+ keySeparator: false,
29
+ ns,
30
+ backend: {
31
+ loadPath: 'https://translations.freckle.com/latest/{{lng}}/{{ns}}'
32
+ }
33
+ })
34
+
35
+ let anyErrors = false
36
+
37
+ for (const l of supportedLngs) {
38
+ await i18next.changeLanguage(l)
39
+ for (const n of ns) {
40
+ for (const key of Object.keys(i18next.getDataByLanguage(l)[n])) {
41
+ try {
42
+ t(`${n}:${key}`)
43
+ } catch (error) {
44
+ if (!isInterpolationError(error)) {
45
+ anyErrors = true
46
+ console.error(`${n}:${l}:${key}`)
47
+ }
48
+ }
49
+ }
50
+ }
51
+ }
52
+
53
+ if (anyErrors) {
54
+ console.error('The above keys cannot be parsed as ICU and need to be fixed in Locize.')
55
+ process.exit(1)
56
+ } else {
57
+ process.exit(0)
58
+ }
59
+ }
60
+
61
+ const isInterpolationError = error => /^The intl string context variable/.test(error.message)
62
+
63
+ main()
@@ -9,7 +9,7 @@ translation_keys_in_code() {
9
9
  git ls-files "$project_path" |
10
10
  grep -E '\.(js|ts|tsx)$' |
11
11
  grep -v '\.min\.js$' |
12
- "$script_dir/translation-keys"
12
+ "$script_dir/translation-keys.js"
13
13
  }
14
14
 
15
15
  translation_keys_in_locize() {
package/dist/helpers.js CHANGED
@@ -1,9 +1,8 @@
1
- #!/usr/bin/env node
2
- const {promises: fs} = require('fs')
3
- const p = require('path')
1
+ import {promises as fs} from 'node:fs'
2
+ import p from 'node:path'
4
3
 
5
4
  // Modified from https://dev.to/leonard/get-files-recursive-with-the-node-js-file-system-fs-2n7o
6
- async function getFiles(path) {
5
+ export async function getFiles(path) {
7
6
  const allEntries = await fs.readdir(path, {withFileTypes: true})
8
7
 
9
8
  const entries = allEntries.filter(file => file.name !== 'node_modules' && file.name !== '.cache')
@@ -21,14 +20,8 @@ async function getFiles(path) {
21
20
  return files
22
21
  }
23
22
 
24
- module.exports.getFiles = getFiles
25
-
26
- const filterJsFiles = f =>
23
+ export const filterJsFiles = f =>
27
24
  (f.name.endsWith('.js') || f.name.endsWith('.ts') || f.name.endsWith('.tsx')) &&
28
25
  !f.name.endsWith('min.js')
29
26
 
30
- module.exports.filterJsFiles = filterJsFiles
31
-
32
- const supportedLngs = ['es', 'en', 'en-GB']
33
-
34
- module.exports.supportedLngs = supportedLngs
27
+ export const supportedLngs = ['es', 'en', 'en-GB']
@@ -1,6 +1,6 @@
1
- const fs = require('fs')
2
- const parser = require('flow-parser')
3
- const walk = require('esprima-walk')
1
+ import fs from 'node:fs'
2
+ import parser from 'flow-parser'
3
+ import walk from 'esprima-walk'
4
4
 
5
5
  const isIdentifierT = node => node && node.type === 'Identifier' && node.name === 't'
6
6
 
@@ -24,8 +24,8 @@ const isObjectWithSimpleProperties = node =>
24
24
  node.type === 'ObjectExpression' &&
25
25
  node.properties.every(p => p.type === 'Property' && p.key.type === 'Identifier')
26
26
 
27
- const KNOWN_VARIABLES_TAG = 'i18n-key-with-known-variables'
28
- const UNKNOWN_VARIABLES_TAG = 'i18n-key-with-unknown-variables'
27
+ export const KNOWN_VARIABLES_TAG = 'i18n-key-with-known-variables'
28
+ export const UNKNOWN_VARIABLES_TAG = 'i18n-key-with-unknown-variables'
29
29
 
30
30
  const knownVariables = (source, i18nKey, objectWithSimpleProperties) => ({
31
31
  tag: KNOWN_VARIABLES_TAG,
@@ -47,9 +47,6 @@ const unknownVariables = (source, i18nKey) => ({
47
47
  i18nKey
48
48
  })
49
49
 
50
- module.exports.KNOWN_VARIABLES_TAG = KNOWN_VARIABLES_TAG
51
- module.exports.UNKNOWN_VARIABLES_TAG = UNKNOWN_VARIABLES_TAG
52
-
53
50
  // Attempt to find all translations keys and their provided variables in use in JS files.
54
51
  //
55
52
  // Methodology:
@@ -67,7 +64,7 @@ module.exports.UNKNOWN_VARIABLES_TAG = UNKNOWN_VARIABLES_TAG
67
64
  // are not known
68
65
  //
69
66
  // Yields keys with variables as encountered without sorting or de-duplication.
70
- module.exports.extractTranslationKeysAndVariables = path => {
67
+ export const extractTranslationKeysAndVariables = path => {
71
68
  const content = fs.readFileSync(path, {encoding: 'utf8'})
72
69
  const ast = parser.parse(content)
73
70
 
@@ -7,21 +7,20 @@
7
7
  // git ls-files |
8
8
  // grep '\.js$' |
9
9
  // grep -v '\.min\.js$' |
10
- // ./scripts/translation-keys
10
+ // ./scripts/translation-keys.js
11
11
  //
12
12
  // We stream out the keys as seen. We do no sorting or de-duplication.
13
13
  //
14
- const {extractTranslationKeysAndVariables} = require('./translation-extraction.js')
15
- const readline = require('readline')
14
+ import readline from 'node:readline'
16
15
 
17
- const interface = readline.createInterface({
16
+ import {extractTranslationKeysAndVariables} from './translation-extraction.js'
17
+
18
+ const lines = readline.createInterface({
18
19
  input: process.stdin,
19
20
  output: process.stdout,
20
21
  terminal: false
21
22
  })
22
23
 
23
- interface.on('line', path => {
24
- extractTranslationKeysAndVariables(path).forEach(({i18nKey}) =>
25
- console.log(i18nKey)
26
- )
24
+ lines.on('line', path => {
25
+ extractTranslationKeysAndVariables(path).forEach(({i18nKey}) => console.log(i18nKey))
27
26
  })
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "@freckle/i18n-scripts",
3
- "version": "1.1.23",
3
+ "version": "3.0.0",
4
4
  "description": "Scripts for i18n",
5
5
  "author": "Renaissance Learning Inc",
6
6
  "license": "MIT",
7
+ "type": "module",
7
8
  "bin": {
8
9
  "check-i18n-variables": "./dist/check-i18n-variables.js",
9
10
  "check-i18next-icu-parser": "./dist/check-i18next-icu-parser.js",
10
- "check-missing-translations": "./dist/check-missing-translations.js"
11
+ "check-missing-translations": "./dist/check-missing-translations"
11
12
  },
12
- "main": "./dist/index.js",
13
+ "files": [
14
+ "dist"
15
+ ],
13
16
  "repository": {
14
17
  "type": "git",
15
18
  "url": "git+https://github.com/freckle/i18n-scripts-js.git"
@@ -22,7 +25,7 @@
22
25
  },
23
26
  "dependencies": {
24
27
  "esprima-walk": "0.1.0",
25
- "flow-parser": "0.332.0",
28
+ "flow-parser": "0.333.0",
26
29
  "i18next": "26.4.2",
27
30
  "i18next-http-backend": "^4.0.2",
28
31
  "i18next-icu": "^2.4.4",
@@ -32,7 +35,7 @@
32
35
  "@freckle/eslint-config": "^1.1.0",
33
36
  "eslint": "^10.11.0",
34
37
  "globals": "^17.12.0",
35
- "prettier": "^3.9.8"
38
+ "prettier": "^3.9.9"
36
39
  },
37
40
  "packageManager": "pnpm@12.6.0+sha512.3ef68f951cb111ac204b4a5a16f0b2ddf0da56a96e0413e81d855d9f0b55ef926714709028e1cd00c405c2c5fb7b9e8ec4dc46777c805d0373c2f2ff00fd20ec"
38
41
  }
@@ -1 +0,0 @@
1
- * @freckle/team-student-activities
@@ -1,27 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- pull_request:
5
- push:
6
- branches: main
7
-
8
- permissions:
9
- contents: read
10
-
11
- jobs:
12
- build:
13
- runs-on: ubuntu-latest
14
- steps:
15
- - uses: actions/checkout@v7
16
- - uses: pnpm/action-setup@v6
17
- - uses: actions/setup-node@v7
18
- with:
19
- node-version-file: .nvmrc
20
- cache: pnpm
21
- - run: pnpm install --frozen-lockfile
22
- - run: pnpm format-check
23
- - run: pnpm lint
24
- - run: pnpm build
25
- - uses: freckle/check-git-clean-action@v1
26
- with:
27
- hint: "Run pnpm build to update your dist/"
@@ -1,19 +0,0 @@
1
- name: Mergeabot
2
-
3
- on:
4
- schedule:
5
- - cron: "0 0 * * *"
6
-
7
- pull_request:
8
-
9
- permissions:
10
- contents: write
11
- pull-requests: write
12
-
13
- jobs:
14
- mergeabot:
15
- runs-on: ubuntu-latest
16
- steps:
17
- - uses: freckle/mergeabot-action@v3
18
- with:
19
- quarantine-days: -1
@@ -1,26 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- push:
5
- branches:
6
- - main
7
- - rc/*
8
-
9
- permissions:
10
- contents: write
11
-
12
- jobs:
13
- release:
14
- runs-on: ubuntu-latest
15
- steps:
16
- - uses: actions/checkout@v7
17
- - uses: cycjimmy/semantic-release-action@v6.0.0
18
- # conventional-changelog-conventionalcommits is held at 9.x: v10 needs
19
- # conventional-changelog-writer@9, which semantic-release@25 does not ship.
20
- with:
21
- extra_plugins: |
22
- conventional-changelog-conventionalcommits@9
23
- env:
24
- FORCE_COLOR: 1
25
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
26
- GITHUB_TOKEN: ${{ github.token }}
package/.nvmrc DELETED
@@ -1 +0,0 @@
1
- 24.21.0
package/.prettierrc DELETED
@@ -1,18 +0,0 @@
1
- {
2
- "printWidth": 100,
3
- "tabWidth": 2,
4
- "useTabs": false,
5
- "semi": false,
6
- "singleQuote": true,
7
- "quoteProps": "as-needed",
8
- "jsxSingleQuote": false,
9
- "trailingComma": "none",
10
- "bracketSpacing": false,
11
- "bracketSameLine": false,
12
- "arrowParens": "avoid",
13
- "requirePragma": false,
14
- "insertPragma": false,
15
- "proseWrap": "preserve",
16
- "htmlWhitespaceSensitivity": "css",
17
- "endOfLine": "auto"
18
- }
package/.releaserc.yaml DELETED
@@ -1,15 +0,0 @@
1
- # .releaserc.yaml
2
- plugins:
3
- - - '@semantic-release/commit-analyzer'
4
- - preset: 'conventionalcommits'
5
- - - '@semantic-release/release-notes-generator'
6
- - preset: 'conventionalcommits'
7
- - - '@semantic-release/github'
8
- - successCommentCondition: false
9
-
10
- - '@semantic-release/npm'
11
-
12
- branches:
13
- - main
14
- - name: rc/*
15
- prerelease: '${name.replace(/^rc\//, "rc-")}'
package/.restyled.yaml DELETED
@@ -1,12 +0,0 @@
1
- restylers:
2
- - clang-format:
3
- enabled: false
4
- - jq:
5
- enabled: false # prefer prettier-json
6
- - prettier:
7
- include:
8
- - 'src/**/*.js'
9
- - whitespace:
10
- include:
11
- - '**/*'
12
- - '!dist/*'
package/RELEASE.md DELETED
@@ -1,14 +0,0 @@
1
- # Release
2
-
3
- This project uses [semantic-release](https://github.com/semantic-release/semantic-release)
4
- with [conventional commits](https://www.conventionalcommits.org/) to trigger releases.
5
-
6
- To trigger a release in this project, merge a commit to `main` prefixed with:
7
-
8
- 1. `fix:` to trigger a patch release,
9
- 1. `feat:` to trigger minor, or
10
- 1. Use `<type>!:` or the `BREAKING CHANGES: <change>` footer to trigger major
11
-
12
- Pre-releases can be made by pushing to an `rc/*` branch.
13
-
14
- For more details, see the [Semantic Release](https://illuminate.atlassian.net/wiki/spaces/PENG/pages/17952735277/Semantic+Release) documentation.
@@ -1,133 +0,0 @@
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()
@@ -1,64 +0,0 @@
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()
@@ -1,16 +0,0 @@
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
- })
@@ -1,27 +0,0 @@
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/eslint.config.mjs DELETED
@@ -1,22 +0,0 @@
1
- import freckle from '@freckle/eslint-config'
2
- import globals from 'globals'
3
-
4
- export default [
5
- // The shared config scopes its rules to `**/*.ts`; this package is plain
6
- // JavaScript, so without retargeting it would lint nothing.
7
- ...freckle.map(config => (config.files ? {...config, files: ['**/*.js']} : config)),
8
- {
9
- files: ['**/*.js'],
10
- languageOptions: {
11
- globals: globals.node
12
- },
13
- rules: {
14
- // typescript-eslint turns this off because tsc reports undefined names;
15
- // nothing type-checks this package, so it has to come back on.
16
- 'no-undef': 'error',
17
- // The sources are CommonJS until the ESM conversion in #97, which drops
18
- // this waiver.
19
- '@typescript-eslint/no-require-imports': 'off'
20
- }
21
- }
22
- ]
package/renovate.json DELETED
@@ -1,6 +0,0 @@
1
- {
2
- "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3
- "extends": [
4
- "local>freckle/renovate-config"
5
- ]
6
- }
@@ -1,133 +0,0 @@
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()
@@ -1,16 +0,0 @@
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
- })
@@ -1,64 +0,0 @@
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()
@@ -1,16 +0,0 @@
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
- })
@@ -1,69 +0,0 @@
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
@@ -1,16 +0,0 @@
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 DELETED
@@ -1,34 +0,0 @@
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
@@ -1,141 +0,0 @@
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
- }