adapt-authoring-lang 1.1.0 → 1.2.1

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/bin/check.js CHANGED
@@ -74,7 +74,7 @@ async function getUsedStrings (translatedStrings) {
74
74
  usedStrings[key].add(f.replace(root, '').split('/')[1]) // only add module name for errors
75
75
  })
76
76
  }))
77
- const sourceFiles = await glob('adapt-authoring-*/**/*.@(js|hbs)', { cwd: root, absolute: true, ignore: '**/node_modules/**' })
77
+ const sourceFiles = await glob('adapt-authoring-*/**/*.@(js|hbs)', { cwd: root, absolute: true, ignore: ['**/node_modules/**', '**/*.spec.js', '**/tests/**'] })
78
78
 
79
79
  await Promise.all(sourceFiles.map(async f => {
80
80
  const contents = (await fs.readFile(f)).toString()
package/index.js CHANGED
@@ -3,3 +3,4 @@
3
3
  * @namespace lang
4
4
  */
5
5
  export { default } from './lib/LangModule.js'
6
+ export { storeStrings, translate, translateError } from './lib/utils.js'
package/lib/LangModule.js CHANGED
@@ -2,6 +2,7 @@ import { AbstractModule } from 'adapt-authoring-core'
2
2
  import fs from 'fs/promises'
3
3
  import { glob } from 'glob'
4
4
  import path from 'path'
5
+ import { storeStrings, translate, translateError } from './utils.js'
5
6
 
6
7
  /**
7
8
  * Module to handle localisation of language strings
@@ -51,18 +52,18 @@ class LangModule extends AbstractModule {
51
52
  await Promise.all(files.map(async f => {
52
53
  try {
53
54
  const contents = JSON.parse((await fs.readFile(f)).toString())
54
- Object.entries(contents).forEach(([k, v]) => this.storeStrings(`${path.basename(f).replace('.json', '')}.${k}`, v))
55
+ Object.entries(contents).forEach(([k, v]) => storeStrings(this.phrases, `${path.basename(f).replace('.json', '')}.${k}`, v))
55
56
  } catch (e) {
56
57
  this.log('error', e.message, f)
57
58
  }
58
59
  }))
59
60
  }
60
61
 
62
+ /**
63
+ * @deprecated Use storeStrings() from 'adapt-authoring-lang' instead
64
+ */
61
65
  storeStrings (key, value) {
62
- const i = key.indexOf('.')
63
- const lang = key.slice(0, i)
64
- if (!this.phrases[lang]) this.phrases[lang] = {}
65
- this.phrases[lang][key.slice(i + 1)] = value
66
+ storeStrings(this.phrases, key, value)
66
67
  }
67
68
 
68
69
  /**
@@ -146,34 +147,7 @@ class LangModule extends AbstractModule {
146
147
  * @return {String}
147
148
  */
148
149
  translate (lang, key, data) {
149
- if (typeof lang !== 'string') {
150
- lang = this.getConfig('defaultLang')
151
- }
152
- if (key.constructor.name.endsWith('Error')) {
153
- return this.translateError(lang, key)
154
- }
155
- const s = this.phrases[lang]?.[key]
156
- if (!s) {
157
- this.log('warn', `missing key '${lang}.${key}'`)
158
- return key
159
- }
160
- if (!data) {
161
- return s
162
- }
163
- return Object.entries(data).reduce((s, [k, v]) => {
164
- // map any errors specified in data
165
- v = Array.isArray(v) ? v.map(v2 => this.translateError(lang, v2)) : this.translateError(lang, v)
166
- s = s.replaceAll(`\${${k}}`, v)
167
- // handle special-case array replacements
168
- if (Array.isArray(v)) {
169
- const matches = [...s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))]
170
- matches.forEach(([replace, data]) => {
171
- const [attrs, delim] = data.split(':')
172
- s = s.replace(replace, v.map(val => attrs.split(',').map(a => Object.prototype.hasOwnProperty.call(val, a) ? val[a] : a)).join(delim))
173
- })
174
- }
175
- return s
176
- }, s)
150
+ return translate(this.phrases, this.getConfig('defaultLang'), msg => this.log('warn', msg), lang, key, data)
177
151
  }
178
152
 
179
153
  /**
@@ -183,7 +157,7 @@ class LangModule extends AbstractModule {
183
157
  * @returns The translated error (if passed error is not an instance of AdaptError, the original value will be returned)
184
158
  */
185
159
  translateError (lang, error) {
186
- return error?.constructor.name.endsWith('Error') ? this.translate(lang, `error.${error.code}`, error.data ?? error) : error
160
+ return translateError(this.phrases, this.getConfig('defaultLang'), msg => this.log('warn', msg), lang, error)
187
161
  }
188
162
  }
189
163
 
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Parses a dotted language key and stores the value in the phrases dictionary
3
+ * @param {Object} phrases The phrases dictionary to store into
4
+ * @param {String} key Key in the format 'lang.namespace.key'
5
+ * @param {String} value The string value to store
6
+ * @memberof lang
7
+ */
8
+ export function storeStrings (phrases, key, value) {
9
+ const i = key.indexOf('.')
10
+ const lang = key.slice(0, i)
11
+ if (!phrases[lang]) phrases[lang] = {}
12
+ phrases[lang][key.slice(i + 1)] = value
13
+ }
@@ -0,0 +1,43 @@
1
+ import { translateError } from './translateError.js'
2
+
3
+ /**
4
+ * Returns translated language string
5
+ * @param {Object} phrases The phrases dictionary
6
+ * @param {String} defaultLang Default language to use when lang is not a string
7
+ * @param {Function} logWarn Logging function for missing keys (receives message string)
8
+ * @param {String} lang The target language (if undefined, the default language will be used)
9
+ * @param {String|AdaptError} key The unique string key (if an AdaptError is passed, the error data will be used for the data param)
10
+ * @param {Object} data Dynamic data to be inserted into translated string
11
+ * @return {String}
12
+ * @memberof lang
13
+ */
14
+ export function translate (phrases, defaultLang, logWarn, lang, key, data) {
15
+ if (typeof lang !== 'string') {
16
+ lang = defaultLang
17
+ }
18
+ if (key.constructor.name.endsWith('Error')) {
19
+ return translateError(phrases, defaultLang, logWarn, lang, key)
20
+ }
21
+ const s = phrases[lang]?.[key]
22
+ if (!s) {
23
+ logWarn(`missing key '${lang}.${key}'`)
24
+ return key
25
+ }
26
+ if (!data) {
27
+ return s
28
+ }
29
+ return Object.entries(data).reduce((s, [k, v]) => {
30
+ // map any errors specified in data
31
+ v = Array.isArray(v) ? v.map(v2 => translateError(phrases, defaultLang, logWarn, lang, v2)) : translateError(phrases, defaultLang, logWarn, lang, v)
32
+ s = s.replaceAll(`\${${k}}`, v)
33
+ // handle special-case array replacements
34
+ if (Array.isArray(v)) {
35
+ const matches = [...s.matchAll(new RegExp(String.raw`\$map{${k}:(.+)}`, 'g'))]
36
+ matches.forEach(([replace, data]) => {
37
+ const [attrs, delim] = data.split(':')
38
+ s = s.replace(replace, v.map(val => attrs.split(',').map(a => Object.prototype.hasOwnProperty.call(val, a) ? val[a] : a)).join(delim))
39
+ })
40
+ }
41
+ return s
42
+ }, s)
43
+ }
@@ -0,0 +1,17 @@
1
+ import { translate } from './translate.js'
2
+
3
+ /**
4
+ * Translates an AdaptError
5
+ * @param {Object} phrases The phrases dictionary
6
+ * @param {String} defaultLang Default language to use when lang is not a string
7
+ * @param {Function} logWarn Logging function for missing keys (receives message string)
8
+ * @param {String} lang The target language
9
+ * @param {AdaptError} error Error to translate
10
+ * @returns The translated error (if passed error is not an instance of AdaptError, the original value will be returned)
11
+ * @memberof lang
12
+ */
13
+ export function translateError (phrases, defaultLang, logWarn, lang, error) {
14
+ return error?.constructor.name.endsWith('Error')
15
+ ? translate(phrases, defaultLang, logWarn, lang, `error.${error.code}`, error.data ?? error)
16
+ : error
17
+ }
package/lib/utils.js ADDED
@@ -0,0 +1,3 @@
1
+ export { storeStrings } from './utils/storeStrings.js'
2
+ export { translate } from './utils/translate.js'
3
+ export { translateError } from './utils/translateError.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-lang",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Localisation for the Adapt authoring tool",
5
5
  "homepage": "https://github.com/taylortom/adapt-authoring-lang",
6
6
  "license": "GPL-3.0",
@@ -14,12 +14,12 @@
14
14
  },
15
15
  "repository": "github:adapt-security/adapt-authoring-lang",
16
16
  "dependencies": {
17
- "adapt-authoring-core": "^1.7.0",
17
+ "adapt-authoring-core": "^2.0.0",
18
18
  "glob": "^13.0.0"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "adapt-authoring-auth": "^1.0.7",
22
- "adapt-authoring-server": "^1.2.1"
22
+ "adapt-authoring-server": "^2.0.0"
23
23
  },
24
24
  "peerDependenciesMeta": {
25
25
  "adapt-authoring-auth": {
@@ -0,0 +1,49 @@
1
+ import { describe, it, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { storeStrings } from '../lib/utils/storeStrings.js'
4
+
5
+ describe('storeStrings()', () => {
6
+ let phrases
7
+
8
+ beforeEach(() => {
9
+ phrases = {}
10
+ })
11
+
12
+ it('should store string with language prefix', () => {
13
+ storeStrings(phrases, 'en.app.test', 'Test Value')
14
+ assert.equal(phrases.en['app.test'], 'Test Value')
15
+ })
16
+
17
+ it('should create language key if not exists', () => {
18
+ storeStrings(phrases, 'fr.app.hello', 'Bonjour')
19
+ assert.ok(phrases.fr)
20
+ assert.equal(phrases.fr['app.hello'], 'Bonjour')
21
+ })
22
+
23
+ it('should store multiple strings for same language', () => {
24
+ storeStrings(phrases, 'en.app.test1', 'Value 1')
25
+ storeStrings(phrases, 'en.app.test2', 'Value 2')
26
+ assert.equal(phrases.en['app.test1'], 'Value 1')
27
+ assert.equal(phrases.en['app.test2'], 'Value 2')
28
+ })
29
+
30
+ it('should handle nested keys with dots', () => {
31
+ storeStrings(phrases, 'en.app.nested.key', 'Nested Value')
32
+ assert.equal(phrases.en['app.nested.key'], 'Nested Value')
33
+ })
34
+
35
+ it('should overwrite existing key', () => {
36
+ storeStrings(phrases, 'en.app.test', 'Original')
37
+ storeStrings(phrases, 'en.app.test', 'Updated')
38
+ assert.equal(phrases.en['app.test'], 'Updated')
39
+ })
40
+
41
+ it('should store strings for multiple languages', () => {
42
+ storeStrings(phrases, 'en.app.hello', 'Hello')
43
+ storeStrings(phrases, 'fr.app.hello', 'Bonjour')
44
+ storeStrings(phrases, 'de.app.hello', 'Hallo')
45
+ assert.equal(phrases.en['app.hello'], 'Hello')
46
+ assert.equal(phrases.fr['app.hello'], 'Bonjour')
47
+ assert.equal(phrases.de['app.hello'], 'Hallo')
48
+ })
49
+ })
@@ -0,0 +1,114 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ import { describe, it, beforeEach, mock } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import { translate } from '../lib/utils/translate.js'
5
+
6
+ describe('translate()', () => {
7
+ let phrases
8
+ const defaultLang = 'en'
9
+ let logWarn
10
+
11
+ beforeEach(() => {
12
+ logWarn = mock.fn()
13
+ phrases = {
14
+ en: {
15
+ 'app.simple': 'Simple text',
16
+ 'app.withdata': 'Hello ${name}',
17
+ 'app.multiple': 'User ${user} has ${count} items',
18
+ 'app.array': 'Items: ${items}',
19
+ 'app.arraymap': 'Names: $map{users:name:, }',
20
+ 'error.TEST_ERROR': 'Test error message'
21
+ },
22
+ fr: {
23
+ 'app.simple': 'Texte simple',
24
+ 'app.withdata': 'Bonjour ${name}'
25
+ }
26
+ }
27
+ })
28
+
29
+ it('should return simple translated string', () => {
30
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.simple'), 'Simple text')
31
+ })
32
+
33
+ it('should return string in specified language', () => {
34
+ assert.equal(translate(phrases, defaultLang, logWarn, 'fr', 'app.simple'), 'Texte simple')
35
+ })
36
+
37
+ it('should return key if translation not found', () => {
38
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.missing'), 'app.missing')
39
+ })
40
+
41
+ it('should call logWarn when key is missing', () => {
42
+ translate(phrases, defaultLang, logWarn, 'en', 'app.missing')
43
+ assert.equal(logWarn.mock.callCount(), 1)
44
+ assert.ok(logWarn.mock.calls[0].arguments[0].includes('app.missing'))
45
+ })
46
+
47
+ it('should use default language when lang is not a string', () => {
48
+ assert.equal(translate(phrases, defaultLang, logWarn, null, 'app.simple'), 'Simple text')
49
+ assert.equal(translate(phrases, defaultLang, logWarn, undefined, 'app.simple'), 'Simple text')
50
+ assert.equal(translate(phrases, defaultLang, logWarn, 42, 'app.simple'), 'Simple text')
51
+ })
52
+
53
+ it('should replace single placeholder with data', () => {
54
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.withdata', { name: 'John' }), 'Hello John')
55
+ })
56
+
57
+ it('should replace multiple placeholders', () => {
58
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.multiple', { user: 'Alice', count: 5 }), 'User Alice has 5 items')
59
+ })
60
+
61
+ it('should leave unreplaced placeholders when data is missing', () => {
62
+ const result = translate(phrases, defaultLang, logWarn, 'en', 'app.withdata', {})
63
+ assert.equal(result, 'Hello ${name}')
64
+ })
65
+
66
+ it('should return string without substitution when no data provided', () => {
67
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.simple'), 'Simple text')
68
+ })
69
+
70
+ it('should handle $map syntax with arrays of objects', () => {
71
+ const users = [
72
+ { name: 'Alice', age: 30 },
73
+ { name: 'Bob', age: 25 }
74
+ ]
75
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.arraymap', { users }), 'Names: Alice, Bob')
76
+ })
77
+
78
+ it('should translate error objects', () => {
79
+ const mockError = {
80
+ constructor: { name: 'AdaptError' },
81
+ code: 'TEST_ERROR',
82
+ data: {}
83
+ }
84
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', mockError), 'Test error message')
85
+ })
86
+
87
+ it('should translate error values in data', () => {
88
+ phrases.en['app.status'] = 'Status: ${err}'
89
+ phrases.en['error.INNER'] = 'inner error'
90
+ const innerError = {
91
+ constructor: { name: 'AdaptError' },
92
+ code: 'INNER',
93
+ data: {}
94
+ }
95
+ assert.equal(translate(phrases, defaultLang, logWarn, 'en', 'app.status', { err: innerError }), 'Status: inner error')
96
+ })
97
+
98
+ it('should translate error values inside arrays in data', () => {
99
+ phrases.en['app.errors'] = 'Errors: ${errs}'
100
+ phrases.en['error.E1'] = 'err one'
101
+ phrases.en['error.E2'] = 'err two'
102
+ const errs = [
103
+ { constructor: { name: 'AdaptError' }, code: 'E1', data: {} },
104
+ { constructor: { name: 'AdaptError' }, code: 'E2', data: {} }
105
+ ]
106
+ const result = translate(phrases, defaultLang, logWarn, 'en', 'app.errors', { errs })
107
+ assert.ok(result.includes('err one'))
108
+ assert.ok(result.includes('err two'))
109
+ })
110
+
111
+ it('should return key when language does not exist', () => {
112
+ assert.equal(translate(phrases, defaultLang, logWarn, 'de', 'app.simple'), 'app.simple')
113
+ })
114
+ })
@@ -0,0 +1,63 @@
1
+ /* eslint-disable no-template-curly-in-string */
2
+ import { describe, it, mock } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+ import { translateError } from '../lib/utils/translateError.js'
5
+
6
+ describe('translateError()', () => {
7
+ const phrases = {
8
+ en: {
9
+ 'error.TEST_CODE': 'Error: ${message}',
10
+ 'error.SIMPLE': 'Simple error'
11
+ }
12
+ }
13
+ const defaultLang = 'en'
14
+ const logWarn = mock.fn()
15
+
16
+ it('should translate error with code', () => {
17
+ const error = {
18
+ constructor: { name: 'AdaptError' },
19
+ code: 'SIMPLE',
20
+ data: {}
21
+ }
22
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', error), 'Simple error')
23
+ })
24
+
25
+ it('should translate error with data', () => {
26
+ const error = {
27
+ constructor: { name: 'TestError' },
28
+ code: 'TEST_CODE',
29
+ data: { message: 'Something went wrong' }
30
+ }
31
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', error), 'Error: Something went wrong')
32
+ })
33
+
34
+ it('should return non-error values unchanged', () => {
35
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', 'just a string'), 'just a string')
36
+ })
37
+
38
+ it('should return null unchanged', () => {
39
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', null), null)
40
+ })
41
+
42
+ it('should return undefined unchanged', () => {
43
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', undefined), undefined)
44
+ })
45
+
46
+ it('should return number values unchanged', () => {
47
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', 42), 42)
48
+ })
49
+
50
+ it('should use error object itself as data when data is missing', () => {
51
+ phrases.en['error.NO_DATA'] = 'Code: ${code}'
52
+ const error = {
53
+ constructor: { name: 'AdaptError' },
54
+ code: 'NO_DATA'
55
+ }
56
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', error), 'Code: NO_DATA')
57
+ })
58
+
59
+ it('should handle objects without constructor gracefully', () => {
60
+ const obj = {}
61
+ assert.equal(translateError(phrases, defaultLang, logWarn, 'en', obj), obj)
62
+ })
63
+ })