adapt-authoring-lang 0.0.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/.eslintignore ADDED
@@ -0,0 +1 @@
1
+ node_modules
package/.eslintrc ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "env": {
3
+ "browser": false,
4
+ "node": true,
5
+ "commonjs": false,
6
+ "es2020": true
7
+ },
8
+ "extends": [
9
+ "standard"
10
+ ],
11
+ "parserOptions": {
12
+ "ecmaVersion": 2020
13
+ }
14
+ }
@@ -0,0 +1,55 @@
1
+ name: Bug Report
2
+ description: File a bug report
3
+ labels: ["bug"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to fill out this bug report!
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: What happened?
13
+ description: Please describe the issue
14
+ validations:
15
+ required: true
16
+ - type: textarea
17
+ id: expected
18
+ attributes:
19
+ label: Expected behaviour
20
+ description: Tell us what should have happened
21
+ - type: textarea
22
+ id: repro-steps
23
+ attributes:
24
+ label: Steps to reproduce
25
+ description: Tell us how to reproduce the issue
26
+ validations:
27
+ required: true
28
+ - type: input
29
+ id: aat-version
30
+ attributes:
31
+ label: Authoring tool version
32
+ description: What version of the Adapt authoring tool are you running?
33
+ validations:
34
+ required: true
35
+ - type: input
36
+ id: fw-version
37
+ attributes:
38
+ label: Framework version
39
+ description: What version of the Adapt framework are you running?
40
+ - type: dropdown
41
+ id: browsers
42
+ attributes:
43
+ label: What browsers are you seeing the problem on?
44
+ multiple: true
45
+ options:
46
+ - Firefox
47
+ - Chrome
48
+ - Safari
49
+ - Microsoft Edge
50
+ - type: textarea
51
+ id: logs
52
+ attributes:
53
+ label: Relevant log output
54
+ description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
55
+ render: sh
@@ -0,0 +1 @@
1
+ blank_issues_enabled: false
@@ -0,0 +1,22 @@
1
+ name: Feature request
2
+ description: Request a new feature
3
+ labels: ["enhancement"]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ Thanks for taking the time to request a new feature in the Adapt authoring tool! The Adapt team will consider all new feature requests, but unfortunately cannot commit to every one.
9
+ - type: textarea
10
+ id: description
11
+ attributes:
12
+ label: Feature description
13
+ description: Please describe your feature request
14
+ validations:
15
+ required: true
16
+ - type: checkboxes
17
+ id: contribute
18
+ attributes:
19
+ label: Can you work on this feature?
20
+ description: If you are able to commit your own time to work on this feature, it will greatly increase the liklihood of it being implemented by the core dev team. Otherwise, it will be triaged and prioritised alongside the core team's current priorities.
21
+ options:
22
+ - label: I can contribute
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for all configuration options:
4
+ # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "npm" # See documentation for possible values
9
+ directory: "/" # Location of package manifests
10
+ schedule:
11
+ interval: "weekly"
@@ -0,0 +1,25 @@
1
+ [//]: # (Please title your PR according to eslint commit conventions)
2
+ [//]: # (See https://github.com/conventional-changelog/conventional-changelog/tree/master/packages/conventional-changelog-eslint#eslint-convention for details)
3
+
4
+ [//]: # (Add a link to the original issue)
5
+
6
+ [//]: # (Delete as appropriate)
7
+ ### Fix
8
+ * A sentence describing each fix
9
+
10
+ ### Update
11
+ * A sentence describing each udpate
12
+
13
+ ### New
14
+ * A sentence describing each new feature
15
+
16
+ ### Breaking
17
+ * A sentence describing each breaking change
18
+
19
+ [//]: # (List appropriate steps for testing if needed)
20
+ ### Testing
21
+ 1. Steps for testing
22
+
23
+ [//]: # (Mention any other dependencies)
24
+
25
+
@@ -0,0 +1,16 @@
1
+ name: Add labelled PRs to project
2
+
3
+ on:
4
+ pull_request:
5
+ types: [ labeled ]
6
+
7
+ jobs:
8
+ add-to-project:
9
+ if: ${{ github.event.label.name == 'dependencies' }}
10
+ name: Add to main project
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/add-to-project@v0.1.0
14
+ with:
15
+ project-url: https://github.com/orgs/adapt-security/projects/5
16
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,19 @@
1
+ name: Add to main project
2
+
3
+ on:
4
+ issues:
5
+ types:
6
+ - opened
7
+ pull_request:
8
+ types:
9
+ - opened
10
+
11
+ jobs:
12
+ add-to-project:
13
+ name: Add to main project
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/add-to-project@v0.1.0
17
+ with:
18
+ project-url: https://github.com/orgs/adapt-security/projects/5
19
+ github-token: ${{ secrets.PROJECTS_SECRET }}
@@ -0,0 +1,6 @@
1
+ {
2
+ "essentialType": "lang",
3
+ "documentation": {
4
+ "enable": true
5
+ }
6
+ }
package/bin/check.js ADDED
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Checks for unused and missing language strings
4
+ */
5
+ import fs from 'fs/promises'
6
+ import { glob } from 'glob'
7
+ import path from 'path'
8
+
9
+ const root = `${process.cwd().replaceAll(path.sep, '/')}/node_modules`
10
+
11
+ async function check () {
12
+ console.log('Checking for unused language strings')
13
+
14
+ const translatedStrings = await getTranslatedStrings()
15
+ const usedStrings = await getUsedStrings(translatedStrings)
16
+
17
+ logUnusedStrings(translatedStrings)
18
+ logMissingStrings(translatedStrings, usedStrings)
19
+ }
20
+
21
+ async function getTranslatedStrings () {
22
+ const langPacks = await glob(`${root}/adapt-authoring-langpack-*/lang`)
23
+ const keyMap = {}
24
+ await Promise.all((langPacks).map(async l => {
25
+ await Promise.all((await fs.readdir(l)).map(async f => {
26
+ const keys = JSON.parse(await fs.readFile(`${l}/${f}`))
27
+ const id = f.split('.')[1]
28
+ Object.keys(keys)
29
+ .map(k => `${id}.${k}`)
30
+ .forEach(k => {
31
+ keyMap[k] = false
32
+ })
33
+ }))
34
+ }))
35
+ return keyMap
36
+ }
37
+
38
+ async function getUsedStrings (translatedStrings) {
39
+ const translatedKeys = Object.keys(translatedStrings)
40
+ const usedStrings = {}
41
+ const errorFiles = await glob(`${root}/adapt-authoring-*/errors/*.json`, { absolute: true })
42
+ await Promise.all(errorFiles.map(async f => {
43
+ Object.keys(JSON.parse((await fs.readFile(f)))).forEach(e => {
44
+ const key = `error.${e}`
45
+ if (!usedStrings[key]) usedStrings[key] = new Set()
46
+ usedStrings[key].add(f.replace(root, '').split('/')[1]) // only add module name for errors
47
+ })
48
+ }))
49
+ const sourceFiles = await glob(`${root}/adapt-authoring-*/**/*.@(js|hbs)`, { absolute: true })
50
+ await Promise.all(sourceFiles.map(async f => {
51
+ const contents = (await fs.readFile(f)).toString()
52
+ translatedKeys.forEach(k => {
53
+ translatedStrings[k] = contents.includes(k) ? true : undefined
54
+ })
55
+ const match = contents.matchAll(/['|"|`|](app\.[\w|\.]+)\W/g)
56
+ if (match) {
57
+ for (const m of match) {
58
+ const key = m[1]
59
+ if (!usedStrings[key]) usedStrings[key] = new Set()
60
+ usedStrings[key].add(f.replace(root, ''))
61
+ }
62
+ }
63
+ }))
64
+ return usedStrings
65
+ }
66
+
67
+ function logUnusedStrings (data) {
68
+ const unusedKeys = Object.entries(data).filter(([k, v]) => !k.startsWith('error.') && !v).map(([k]) => k)
69
+ console.log('')
70
+ if (unusedKeys.length) {
71
+ unusedKeys.forEach(k => console.log(`- ${k}`))
72
+ console.log(`\n${unusedKeys.length} unused language strings found`)
73
+ process.exitCode = 1
74
+ } else {
75
+ console.log('\nNo unused strings!')
76
+ }
77
+ }
78
+
79
+ function logMissingStrings (translatedStrings, usedStrings) {
80
+ console.log('')
81
+ const translatedKeys = Object.keys(translatedStrings)
82
+ const missingStrings = Object.entries(usedStrings).filter(([key]) => !translatedKeys.includes(key) && key !== 'app.js')
83
+ if (missingStrings.length) {
84
+ const sep = '\n => '
85
+ missingStrings.forEach(([key, files]) => console.log(`- ${key}${sep}${Array.from(files).join(sep)}`))
86
+ console.log(`\n${missingStrings.length} missing language strings found`)
87
+ process.exitCode = 1
88
+ } else {
89
+ console.log('\nNo missing strings!')
90
+ }
91
+ }
92
+
93
+ check()
@@ -0,0 +1,11 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "defaultLang": {
6
+ "description": "The default language used by the server",
7
+ "type": "string",
8
+ "default": "en"
9
+ }
10
+ }
11
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "UNKNOWN_LANG": {
3
+ "data": {
4
+ "lang": "language"
5
+ },
6
+ "description": "unknown language",
7
+ "statusCode": 400
8
+ }
9
+ }
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Internationalisation of language strings
3
+ * @namespace lang
4
+ */
5
+ export { default } from './lib/LangModule.js'
@@ -0,0 +1,190 @@
1
+ import { AbstractModule } from 'adapt-authoring-core'
2
+ import fs from 'fs/promises'
3
+ import { glob } from 'glob'
4
+ import path from 'path'
5
+
6
+ /**
7
+ * Module to handle localisation of language strings
8
+ * @memberof lang
9
+ * @extends {AbstractModule}
10
+ */
11
+ class LangModule extends AbstractModule {
12
+ /** @override */
13
+ async init () {
14
+ this.app.lang = this
15
+ await this.loadPhrases()
16
+ this.loadRoutes()
17
+ }
18
+
19
+ /**
20
+ * Returns the languages supported by the application
21
+ * @type {Array<String>}
22
+ */
23
+ get supportedLanguages () {
24
+ return Object.keys(this.phrases)
25
+ }
26
+
27
+ /**
28
+ * Loads, validates and merges all defined langage phrases
29
+ * @return {Promise}
30
+ */
31
+ async loadPhrases () {
32
+ /**
33
+ * The loaded language phrases to be used for translation
34
+ * @type {Object}
35
+ */
36
+ this.phrases = {}
37
+ const deps = [
38
+ { name: this.app.name, rootDir: process.cwd() },
39
+ ...Object.values(this.app.dependencies)
40
+ ]
41
+ return Promise.all(deps.map(async d => this.loadPhrasesForDir(d.rootDir)))
42
+ }
43
+
44
+ /**
45
+ * Load all lang phrases for a given directory
46
+ * @param {String} dir Directory to search
47
+ * @return {Promise} Resolves with the phrases
48
+ */
49
+ async loadPhrasesForDir (dir) {
50
+ const files = await glob('lang/*.json', { cwd: dir, absolute: true })
51
+ await Promise.all(files.map(async f => {
52
+ try {
53
+ 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
+ } catch (e) {
56
+ this.log('error', e.message, f)
57
+ }
58
+ }))
59
+ }
60
+
61
+ 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
+ }
67
+
68
+ /**
69
+ * Loads the router & routes
70
+ * @return {Promise}
71
+ */
72
+ async loadRoutes () {
73
+ const [auth, server] = await this.app.waitForModule('auth', 'server')
74
+
75
+ server.api.addMiddleware(this.addTranslationUtils.bind(this))
76
+
77
+ const router = server.api.createChildRouter('lang')
78
+ router.addRoute({
79
+ route: '/{:lang}',
80
+ handlers: { get: this.requestHandler.bind(this) },
81
+ meta: {
82
+ get: {
83
+ summary: 'Retrieve lang strings for single locale',
84
+ responses: {
85
+ 200: {
86
+ description: 'Lang strings for the specified locale',
87
+ content: {
88
+ 'application/json': {}
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ })
95
+ auth.unsecureRoute(router.path, 'get')
96
+ }
97
+
98
+ /**
99
+ * Load all lang phrases for a language
100
+ * @param {String} lang The language of strings to load
101
+ * @return {Object} The phrases
102
+ */
103
+ getPhrasesForLang (lang) {
104
+ const phrases = {}
105
+ Object.entries(this.phrases).forEach(([key, value]) => {
106
+ const i = key.indexOf('.')
107
+ const keyLang = key.slice(0, i)
108
+ const newKey = key.slice(i + 1)
109
+ if (keyLang === lang) phrases[newKey] = value
110
+ })
111
+ return Object.keys(phrases).length > 1 ? phrases : undefined
112
+ }
113
+
114
+ /**
115
+ * Adds a translate function to incoming API requests for generating language strings in the original request's supported language
116
+ * @param {external:ExpressRequest} req
117
+ * @param {external:ExpressResponse} res
118
+ * @param {function} next
119
+ */
120
+ addTranslationUtils (req, res, next) {
121
+ const lang = req.acceptsLanguages(this.supportedLanguages)
122
+ req.translate = (key, data) => this.translate(lang, key, data)
123
+ next()
124
+ }
125
+
126
+ /**
127
+ * Shortcut to log a missing language key
128
+ * @param {external:ExpressRequest} req The client request object
129
+ * @param {external:ExpressResponse} res The server response object
130
+ * @param {Function} next The callback function
131
+ */
132
+ requestHandler (req, res, next) {
133
+ // defaults to the request (browser) lang
134
+ const lang = req.params.lang || req.acceptsLanguages(this.supportedLanguages)
135
+ if (!lang || !this.phrases[lang]) {
136
+ return next(this.app.errors.UNKNOWN_LANG.setData({ lang }))
137
+ }
138
+ res.json(this.phrases[lang])
139
+ }
140
+
141
+ /**
142
+ * Returns translated language string
143
+ * @param {String} lang The target language (if undefined, the default server language will be used)
144
+ * @param {String|AdaptError} key The unique string key (if an AdaptError is passed, the error data will be used for the data param)
145
+ * @param {Object} data Dynamic data to be inserted into translated string
146
+ * @return {String}
147
+ */
148
+ translate (lang, key, data) {
149
+ if (typeof lang !== 'string') {
150
+ lang = this.getConfig('defaultLang')
151
+ }
152
+ if (key.constructor.name === 'AdaptError') {
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)
177
+ }
178
+
179
+ /**
180
+ * Translates an AdaptError
181
+ * @param {String} lang The target language
182
+ * @param {AdaptError} error Error to translate
183
+ * @returns The translated error (if passed error is not an instance of AdaptError, the original value will be returned)
184
+ */
185
+ translateError (lang, error) {
186
+ return error?.constructor?.name === 'AdaptError' ? this.translate(lang, `error.${error.code}`, error.data) : error
187
+ }
188
+ }
189
+
190
+ export default LangModule
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "adapt-authoring-lang",
3
+ "version": "0.0.1",
4
+ "description": "Localisation for the Adapt authoring tool",
5
+ "homepage": "https://github.com/taylortom/adapt-authoring-lang",
6
+ "license": "GPL-3.0",
7
+ "type": "module",
8
+ "main": "index.js",
9
+ "bin": {
10
+ "at-langcheck": "./bin/check.js"
11
+ },
12
+ "repository": "github:adapt-security/adapt-authoring-lang",
13
+ "dependencies": {
14
+ "glob": "^11.0.0"
15
+ },
16
+ "devDependencies": {
17
+ "eslint": "^9.12.0",
18
+ "standard": "^17.1.0"
19
+ }
20
+ }