@transifex/express 2.4.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/.eslintrc.json ADDED
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": ["airbnb-base"],
3
+ "rules": {
4
+ "import/no-extraneous-dependencies": "off",
5
+ "no-plusplus": "off",
6
+ "no-underscore-dangle": "off"
7
+ }
8
+ }
package/README.md ADDED
@@ -0,0 +1,472 @@
1
+ # Transifex Native integration for Express JS
2
+
3
+ ## Quick start
4
+
5
+ Install the necessary express packages:
6
+
7
+ ```shell
8
+ npm install --save express cookie-parser body-parser ...
9
+ ```
10
+
11
+ And the Transifex Native integration:
12
+
13
+ ```shell
14
+ npm install --save @transifex/native @transifex/express
15
+ ```
16
+
17
+ Create an express app and attach the necessary middleware:
18
+
19
+ ```javascript
20
+ const express = require('express');
21
+ const cookieParser = require('cookie-parser');
22
+ const bodyParser = require('body-parser');
23
+
24
+ const app = express();
25
+ app.use(cookieParser());
26
+ app.use(bodyParser.urlencoded({ extended: false }));
27
+ ```
28
+
29
+ Import the Transifex Native libraries and set up:
30
+
31
+ ```javascript
32
+ const { TxExpress } = require('@transifex/express');
33
+
34
+ const txExpress = new TxExpress({ token: '...' });
35
+ app.use(txExpress.middleware());
36
+ app.post('/i18n', txExpress.setLocale());
37
+ ```
38
+
39
+ > All options passed to the `TxExpress`'s constructor that are not handled by it
40
+ > will be passed on to `tx.init` internally. If you have already initialized the
41
+ > `tx` object, you do not have to supply these options.
42
+ >
43
+ > ```javascript
44
+ > const txExpress = new TxExpress({
45
+ > // TxExpress options
46
+ > daemon: true,
47
+ > ttl: 2 * 60,
48
+ >
49
+ > // tx options
50
+ > token: '...',
51
+ > filterTags: 'mytags',
52
+ > });
53
+ >
54
+ > // is equivalent to
55
+ >
56
+ > const { tx } from '@transifex/native';
57
+ > tx.init({ token: '...', filterTags: 'mytags' })
58
+ > const txExpress = new TxExpress({ daemon: true, ttl: 2 * 60 });
59
+ > ```
60
+
61
+ Finally, fetch available languages and translations and start the server:
62
+
63
+ ```javascript
64
+ txExpress.fetch().then(() => {
65
+ app.listen(3000, () => {
66
+ console.log('App listening on port 3000');
67
+ });
68
+ });
69
+ ```
70
+
71
+ ### `txExpress.middleware()` middleware
72
+
73
+ ```javascript
74
+ app.use(txExpress.middleware());
75
+ ```
76
+
77
+ The middleware will make sure that you have a `req.t` function to translate the
78
+ argument to the user's selected language.
79
+
80
+ ```javascript
81
+ app.get('/', (req, res) => {
82
+ res.send(req.t('Hello world!'));
83
+ });
84
+ ```
85
+
86
+ The `t`-function has the same interface as `@transifex/native`'s `t`-function.
87
+ So, you can pass all extra arguments, like this:
88
+
89
+ ```javascript
90
+ app.get('/', (req, res) => {
91
+ res.send(req.t('Hello world!', { _context: 'foo', _tags: 'bar' }));
92
+ });
93
+ ```
94
+
95
+ The middleware will also make sure that any templates that are rendered by
96
+ Express will have a `t`-function and a `tx` object in their context. The
97
+ `t`-function will take care of translation (in the same way as `req.t` does)
98
+ and the `tx` object holds a list of available languages and the currently
99
+ selected language code (`tx.languages` and `tx.currentLocale` respectively).
100
+ Using this, you can do:
101
+
102
+ ```javascript
103
+ // index.js
104
+ app.set('views', './views');
105
+ app.set('view engine', 'pug');
106
+
107
+ app.get('/', (req, res) => {
108
+ res.render('index.pug');
109
+ });
110
+ ```
111
+
112
+
113
+ ```pug
114
+ // views/index.pug
115
+ html
116
+ body
117
+ form(method='POST' action='/i18n')
118
+ select(name='locale')
119
+ each locale in tx.languages
120
+ option(
121
+ value=locale.code
122
+ selected=locale.code === tx.currentLocale
123
+ )= locale.name
124
+ input(type='submit' value="Change language")
125
+ p= t('Hello World!')
126
+ ```
127
+
128
+ This will render a language-select dropdown (with the list of languages
129
+ dynamically fetched by Transifex Native) and a translated string.
130
+
131
+ This (having `t` and `tx` available in the template's context) works regardless
132
+ of which template engine is being used.
133
+
134
+ ### Escaping strings
135
+
136
+ Normally, interpolating strings in HTML that is to be rendered by a browser can
137
+ make your application vulnerable to XSS attacks. For this purpose, the
138
+ `t`-function in the express integration (both `req.t` and the `t`-function that
139
+ is available to the template's context) return the escaped version of the
140
+ rendered string. If you are confident that your string is safe to use inside
141
+ HTML or that your template engine takes care of escaping for you, then you must
142
+ use `ut` (available both as `req.ut` and as the `ut` function in your
143
+ templates). Also, be careful of double escaping:
144
+
145
+ ```javascript
146
+ // index.js
147
+
148
+ app.get('/', (req, res) => {
149
+ // This will send 'hello &lt;world&gt;' and it will appear as 'hello <world>'
150
+ // in the browser
151
+ res.send(req.t('hello <world>'));
152
+
153
+ // This will send 'hello <world>' and it is dangerous to show in the browser
154
+ res.send(req.ut('hello <world>'));
155
+ })
156
+ ```
157
+
158
+ ```pug
159
+ // views/index.pug
160
+
161
+ // These will send 'hello &amp;lt;world&amp;gt;' and they will appear as
162
+ // 'hello &lt;world&gt;' in the browser
163
+ p #{t('hello <world>')}
164
+ p= t('hello <world>')
165
+
166
+ // These will send 'hello &lt;world&gt;' and they will appear as
167
+ // 'hello <world>' in the browser
168
+ p #{ut('hello <world>')}
169
+ p= ut('hello <world>')
170
+
171
+ // These will send 'hello &lt;world&gt;' and they will appear as
172
+ // 'hello <world>' in the browser
173
+ p !{t('hello <world>')}
174
+ p!= t('hello <world>')
175
+
176
+ // These will send 'hello <world>' and they are dangerous to show in the
177
+ // browser
178
+ p !{ut('hello <world>')}
179
+ p!= ut('hello <world>')
180
+ ```
181
+
182
+ ### `txExpress.setLocale()` handler
183
+
184
+ ```javascript
185
+ app.post('/i18n', txExpress.setLocale());
186
+ ```
187
+
188
+ The `txExpress.setLocale()` endpoint handler (mapped to `/i18n` in the example)
189
+ is used by the user to change their selected language. The form to make this
190
+ happen could look like this:
191
+
192
+ ```html
193
+ <form method="POST" action="/i18n">
194
+ <input type="hidden" name="next" value="/current_url" />
195
+ <select name="locale">
196
+ <option value="en">English</option>
197
+ <option value="el">Greek</option>
198
+ <option value="fr">French</option>
199
+ </select>
200
+ <input type="submit" value="change language" />
201
+ </form>
202
+ ```
203
+
204
+ The value of `next` will determine where the user will be redirected to after
205
+ the form is submitted. If `next` is missing, then the user will be redirected
206
+ to the value of `req.headers.referer` which is the page where the form
207
+ originated from.
208
+
209
+ If you make an AJAX POST request with a JSON Content-Type to this endpoint with
210
+ a `locale` field, the server will respond with a `{"status": "success"}` reply,
211
+ after having changed the user's selected language (it will be up to you to
212
+ reload the page if you want to).
213
+
214
+ ## Modes
215
+
216
+ The user's selected language can be saved and retrieved with a number of
217
+ available modes:
218
+
219
+ ### Cookie (default)
220
+
221
+ This saves the selected language on a cookie named after the value of 'options.name'.
222
+
223
+ ```javascript
224
+ const { TxExpress, CookieMode } = require('@transifex/express');
225
+ const txExpress = new TxExpress({
226
+ mode: CookieMode({ name: 'my-tx-cookie' }),
227
+ });
228
+ ```
229
+
230
+ It must be used alongside `cookie-parser`:
231
+
232
+ ```javascript
233
+ const express = require('express');
234
+ const bodyParser = require('body-parser');
235
+ const app = express();
236
+ app.use(bodyParser.urlencoded({ extended: false }));
237
+
238
+ const cookieParser = require('cookie-parser');
239
+ app.use(cookieParser());
240
+
241
+ const { TxExpress, CookieMode } = require('@transifex/express');
242
+ const txExpress = new TxExpress({
243
+ token: '...',
244
+ mode: CookieMode({ name: 'my-tx-cookie' }),
245
+ });
246
+
247
+ app.use(txExpress.middleware());
248
+ app.post('/i18n', txExpress.setLocale());
249
+ app.get('/', (req, res) => { res.send(req.t('Hello world!')); });
250
+ ```
251
+
252
+ Also accepts the `cookieOptions` option which will be forwarded to `req.cookie()`.
253
+
254
+ ### Signed cookie
255
+
256
+ This saves the selected language on a signed cookie named after the value of
257
+ 'options.name'.
258
+
259
+ ```javascript
260
+ const { TxExpress, SignedCookieMode } = require('@transifex/express');
261
+ const txExpress = new TxExpress({
262
+ mode: SignedCookieMode({ name: 'my-tx-cookie' }),
263
+ });
264
+ ```
265
+
266
+ It must be used alongside `cookie-parser` which needs to be supplied with a secret:
267
+
268
+ ```javascript
269
+ const express = require('express');
270
+ const bodyParser = require('body-parser');
271
+ const app = express();
272
+ app.use(bodyParser.urlencoded({ extended: false }));
273
+
274
+ const cookieParser = require('cookie-parser');
275
+ app.use(cookieParser('mysecret'));
276
+
277
+ const { TxExpress, SignedCookieMode } = require('@transifex/express');
278
+ const txExpress = new TxExpress({
279
+ token: '...',
280
+ mode: SignedCookieMode({ name: 'my-tx-cookie' }),
281
+ });
282
+
283
+ app.use(txExpress.middleware());
284
+ app.post('/i18n', txExpress.setLocale());
285
+ app.get('/', (req, res) => { res.send(req.t('Hello world!')); });
286
+ ```
287
+
288
+ Also accepts the `cookieOptions` option which will be forwarded to `req.cookie()`.
289
+
290
+ ### Session
291
+
292
+ This saves the selected language on a session variable named after the value of
293
+ 'options.name'.
294
+
295
+ ```javascript
296
+ const { TxExpress, SessionMode } = require('@transifex/express');
297
+ const txExpress = new TxExpress({
298
+ mode: SessionMode({ name: 'my-tx-cookie' }),
299
+ });
300
+ ```
301
+
302
+ It must be used alongside `express-session` or `cookie-session`:
303
+
304
+ ```javascript
305
+ const express = require('express');
306
+ const bodyParser = require('body-parser');
307
+ const app = express();
308
+ app.use(bodyParser.urlencoded({ extended: false }));
309
+
310
+ const session = require('express-session');
311
+ // or
312
+ const cookieSession = require('cookie-session');
313
+
314
+ app.use(session({ secret: 'mysecret', ... }));
315
+ // or
316
+ app.use(cookieSession({ keys: ['mysecret'], ... }));
317
+
318
+ const { TxExpress, SessionMode } = require('@transifex/express');
319
+
320
+ const txExpress = new TxExpress({
321
+ token: '...',
322
+ mode: SessionMode({ name: 'my-tx-cookie' }),
323
+ });
324
+
325
+ app.use(txExpress.middleware());
326
+ app.post('/i18n', txExpress.setLocale());
327
+ app.get('/', (req, res) => { res.send(req.t('Hello world!')); });
328
+ ```
329
+
330
+ ### Custom modes
331
+
332
+ The values for the `mode` options are objects that implement the
333
+ `setLocale(req, res, locale)` and `getLocale(req, res)` functions. You can
334
+ easily implement your own. A sample implementation could look like this:
335
+
336
+ ```javascript
337
+ const myMode = {
338
+ userLocales: {}, // User ID to selected locale map
339
+ setLocale(req, res, locale) {
340
+ this.userLocales[req.cookies.userId] = locale;
341
+ },
342
+ getLocale(req, res) {
343
+ return this.userLocales[req.cookies.userId];
344
+ },
345
+ };
346
+
347
+ const txExpress = new TxExpress({ mode: myMode });
348
+ ```
349
+
350
+ ## Extracting strings with `txjs-cli`
351
+
352
+ The `txjs-cli` program from the `@transifex/cli` package will manage to extract
353
+ invocations of the `req.t` function in your source code, as well as invocations
354
+ of the `t` function in '.pug' and '.ejs' templates.
355
+
356
+ ```shell
357
+ ➜ npm install @transifex/cli
358
+
359
+ ➜ npx txjs-cli push views -v
360
+
361
+ Parsing all files to detect translatable content...
362
+ ✓ Processed 2 file(s) and found 2 translatable phrases.
363
+ ✓ Content detected in 2 file(s).
364
+ /views/index.ejs
365
+ └─ This string originated from a EJS file
366
+ └─ occurrences: ["/views/index.ejs"]
367
+ /views/index.pug
368
+ └─ This string originated from a PUG file
369
+ └─ occurrences: ["/views/index.pug"]
370
+
371
+ Uploading content to Transifex... Success
372
+ ✓ Successfully pushed strings to Transifex:
373
+ Created strings: 2
374
+ ```
375
+
376
+ It is easy to enhance support for express template engines in `txjs-cli`,
377
+ especially if the template engine in question works by converting a template to
378
+ javascript code that can be then fed to the normal extraction process. In fact,
379
+ this in the only piece of code that was needed in order to extend support to
380
+ .pug and .ejs templates:
381
+
382
+ ```javascript
383
+ // transifex-javascript/packages/cli/src/api/extract.js
384
+
385
+ function extractPhrases(file, relativeFile, options = {}) {
386
+
387
+ // ...
388
+
389
+ let source = fs.readFileSync(file, 'utf8');
390
+
391
+ if (path.extname(file) === '.pug') {
392
+ source = pug.compileClient(source);
393
+ } else if (path.extname(file) === '.ejs') {
394
+ const template = new ejs.Template(source);
395
+ template.generateSource();
396
+ source = template.source;
397
+ }
398
+
399
+ // ...
400
+ }
401
+ ```
402
+
403
+ So, if your template engine of choice is not supported by `txjs-cli` yet,
404
+ please consider contributing a pull request 😉.
405
+
406
+ ## API
407
+
408
+ ### TxExpress
409
+
410
+ ```javascript
411
+ new TxExpress({
412
+
413
+ // How to save the selected language. Must implement the `setLocale(req, res,
414
+ // locale)` and `getLocale(req, res)` methods. Builtin modes: `CookieMode`,
415
+ // `SignedCookieMode`, `SessionMode`.
416
+ mode: Object,
417
+
418
+ // Whether to fall back to the request's 'Accept-Language' header (set by the
419
+ // browser) if the selected language isn't set, default: true
420
+ fallBackToAcceptLanguage: Boolean
421
+
422
+ // The locale to fall back to if both the mode and the 'Accept-Language'
423
+ // header fail to produce a result, default: 'en'
424
+ sourceLocale: String,
425
+
426
+ // If the server should periodically refetch translations from Transifex,
427
+ // default: true
428
+ daemon: Boolean,
429
+
430
+ // If daemon is true, how often to refetch translations in seconds, default:
431
+ // 10 minutes
432
+ ttl: Integer,
433
+
434
+ // How to display log messages; a straightforward option would be
435
+ // `console.log`, default: noop
436
+ logging: Function
437
+ })
438
+ ```
439
+
440
+ ### CookieMode
441
+
442
+ ```javascript
443
+ CookieMode({
444
+ // The name of the cookie to be used
445
+ name: String,
446
+
447
+ // Extra options passed to the `req.cookie()` function
448
+ cookieOptions: Object,
449
+ });
450
+ ```
451
+
452
+ ### SignedCookieMode
453
+
454
+ ```javascript
455
+ SignedCookieMode({
456
+ // The name of the cookie to be used
457
+ name: String,
458
+
459
+ // Extra options passed to the `req.cookie()` function; the `signed: true`
460
+ // option will always be set
461
+ cookieOptions: Object,
462
+ });
463
+ ```
464
+
465
+ ### SessionMode
466
+
467
+ ```javascript
468
+ SessionMode({
469
+ // The name of the session field to be used
470
+ name: String,
471
+ });
472
+ ```
package/jest.config.js ADDED
@@ -0,0 +1,194 @@
1
+ /*
2
+ * For a detailed explanation regarding each configuration property, visit:
3
+ * https://jestjs.io/docs/configuration
4
+ */
5
+
6
+ module.exports = {
7
+ // All imported modules in your tests should be mocked automatically
8
+ // automock: false,
9
+
10
+ // Stop running tests after `n` failures
11
+ // bail: 0,
12
+
13
+ // The directory where Jest should store its cached dependency information
14
+ // cacheDirectory: "/tmp/jest_rs",
15
+
16
+ // Automatically clear mock calls, instances and results before every test
17
+ clearMocks: true,
18
+
19
+ // Indicates whether the coverage information should be collected while executing the test
20
+ collectCoverage: true,
21
+
22
+ // An array of glob patterns indicating a set of files for which coverage information should be collected
23
+ // collectCoverageFrom: undefined,
24
+
25
+ // The directory where Jest should output its coverage files
26
+ coverageDirectory: "coverage",
27
+
28
+ // An array of regexp pattern strings used to skip coverage collection
29
+ // coveragePathIgnorePatterns: [
30
+ // "/node_modules/"
31
+ // ],
32
+
33
+ // Indicates which provider should be used to instrument code for coverage
34
+ coverageProvider: "v8",
35
+
36
+ // A list of reporter names that Jest uses when writing coverage reports
37
+ // coverageReporters: [
38
+ // "json",
39
+ // "text",
40
+ // "lcov",
41
+ // "clover"
42
+ // ],
43
+
44
+ // An object that configures minimum threshold enforcement for coverage results
45
+ // coverageThreshold: undefined,
46
+
47
+ // A path to a custom dependency extractor
48
+ // dependencyExtractor: undefined,
49
+
50
+ // Make calling deprecated APIs throw helpful error messages
51
+ // errorOnDeprecated: false,
52
+
53
+ // Force coverage collection from ignored files using an array of glob patterns
54
+ // forceCoverageMatch: [],
55
+
56
+ // A path to a module which exports an async function that is triggered once before all test suites
57
+ // globalSetup: undefined,
58
+
59
+ // A path to a module which exports an async function that is triggered once after all test suites
60
+ // globalTeardown: undefined,
61
+
62
+ // A set of global variables that need to be available in all test environments
63
+ // globals: {},
64
+
65
+ // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
66
+ // maxWorkers: "50%",
67
+
68
+ // An array of directory names to be searched recursively up from the requiring module's location
69
+ // moduleDirectories: [
70
+ // "node_modules"
71
+ // ],
72
+
73
+ // An array of file extensions your modules use
74
+ // moduleFileExtensions: [
75
+ // "js",
76
+ // "jsx",
77
+ // "ts",
78
+ // "tsx",
79
+ // "json",
80
+ // "node"
81
+ // ],
82
+
83
+ // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
84
+ // moduleNameMapper: {},
85
+
86
+ // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
87
+ // modulePathIgnorePatterns: [],
88
+
89
+ // Activates notifications for test results
90
+ // notify: false,
91
+
92
+ // An enum that specifies notification mode. Requires { notify: true }
93
+ // notifyMode: "failure-change",
94
+
95
+ // A preset that is used as a base for Jest's configuration
96
+ // preset: undefined,
97
+
98
+ // Run tests from one or more projects
99
+ // projects: undefined,
100
+
101
+ // Use this configuration option to add custom reporters to Jest
102
+ // reporters: undefined,
103
+
104
+ // Automatically reset mock state before every test
105
+ // resetMocks: false,
106
+
107
+ // Reset the module registry before running each individual test
108
+ // resetModules: false,
109
+
110
+ // A path to a custom resolver
111
+ // resolver: undefined,
112
+
113
+ // Automatically restore mock state and implementation before every test
114
+ // restoreMocks: false,
115
+
116
+ // The root directory that Jest should scan for tests and modules within
117
+ // rootDir: undefined,
118
+
119
+ // A list of paths to directories that Jest should use to search for files in
120
+ // roots: [
121
+ // "<rootDir>"
122
+ // ],
123
+
124
+ // Allows you to use a custom runner instead of Jest's default test runner
125
+ // runner: "jest-runner",
126
+
127
+ // The paths to modules that run some code to configure or set up the testing environment before each test
128
+ // setupFiles: [],
129
+
130
+ // A list of paths to modules that run some code to configure or set up the testing framework before each test
131
+ // setupFilesAfterEnv: [],
132
+
133
+ // The number of seconds after which a test is considered as slow and reported as such in the results.
134
+ // slowTestThreshold: 5,
135
+
136
+ // A list of paths to snapshot serializer modules Jest should use for snapshot testing
137
+ // snapshotSerializers: [],
138
+
139
+ // The test environment that will be used for testing
140
+ // testEnvironment: "jest-environment-node",
141
+
142
+ // Options that will be passed to the testEnvironment
143
+ // testEnvironmentOptions: {},
144
+
145
+ // Adds a location field to test results
146
+ // testLocationInResults: false,
147
+
148
+ // The glob patterns Jest uses to detect test files
149
+ // testMatch: [
150
+ // "**/__tests__/**/*.[jt]s?(x)",
151
+ // "**/?(*.)+(spec|test).[tj]s?(x)"
152
+ // ],
153
+
154
+ // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
155
+ // testPathIgnorePatterns: [
156
+ // "/node_modules/"
157
+ // ],
158
+
159
+ // The regexp pattern or array of patterns that Jest uses to detect test files
160
+ // testRegex: [],
161
+
162
+ // This option allows the use of a custom results processor
163
+ // testResultsProcessor: undefined,
164
+
165
+ // This option allows use of a custom test runner
166
+ // testRunner: "jest-circus/runner",
167
+
168
+ // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
169
+ // testURL: "http://localhost",
170
+
171
+ // Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
172
+ // timers: "real",
173
+
174
+ // A map from regular expressions to paths to transformers
175
+ // transform: undefined,
176
+
177
+ // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
178
+ // transformIgnorePatterns: [
179
+ // "/node_modules/",
180
+ // "\\.pnp\\.[^\\/]+$"
181
+ // ],
182
+
183
+ // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
184
+ // unmockedModulePathPatterns: undefined,
185
+
186
+ // Indicates whether each individual test should be reported during the run
187
+ // verbose: undefined,
188
+
189
+ // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
190
+ // watchPathIgnorePatterns: [],
191
+
192
+ // Whether to use watchman for file crawling
193
+ // watchman: true,
194
+ };
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "@transifex/express",
3
+ "version": "2.4.1",
4
+ "description": "Transifex Native for Express JS",
5
+ "keywords": [
6
+ "transifex",
7
+ "i18n",
8
+ "l10n",
9
+ "localization"
10
+ ],
11
+ "author": "Transifex",
12
+ "homepage": "https://github.com/transifex/transifex-javascript/tree/master/packages/express",
13
+ "license": "Apache-2.0",
14
+ "main": "src/index.js",
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "repository": "git://github.com/transifex/transifex-javascript.git",
19
+ "scripts": {
20
+ "lint": "eslint src/",
21
+ "build": "",
22
+ "test": "jest",
23
+ "publish-npm": "npm publish"
24
+ },
25
+ "bugs": {
26
+ "url": "https://github.com/transifex/transifex-javascript/issues"
27
+ },
28
+ "engines": {
29
+ "node": ">=12.0.0"
30
+ },
31
+ "devDependencies": {
32
+ "@transifex/native": "^2.4.1",
33
+ "eslint": "^8.10.0",
34
+ "eslint-config-airbnb-base": "^15.0.0",
35
+ "eslint-plugin-import": "^2.25.4",
36
+ "jest": "^27.5.1"
37
+ },
38
+ "dependencies": {
39
+ "type-is": "^1.6.18"
40
+ },
41
+ "peerDependencies": {
42
+ "@transifex/native": "^2.4.1"
43
+ }
44
+ }
package/src/index.js ADDED
@@ -0,0 +1,180 @@
1
+ const { tx, escape } = require('@transifex/native');
2
+ const typeis = require('type-is');
3
+
4
+ function CookieMode({ name = 'tx-locale', cookieOptions } = {}) {
5
+ return {
6
+ setLocale(req, res, locale) {
7
+ res.cookie(name, locale, cookieOptions);
8
+ },
9
+ getLocale(req) {
10
+ return req.cookies[name];
11
+ },
12
+ };
13
+ }
14
+
15
+ function SignedCookieMode({ name = 'tx-locale', cookieOptions } = {}) {
16
+ return {
17
+ setLocale(req, res, locale) {
18
+ res.cookie(name, locale, { ...cookieOptions, signed: true });
19
+ },
20
+ getLocale(req) {
21
+ return req.signedCookies[name];
22
+ },
23
+ };
24
+ }
25
+
26
+ function SessionMode({ name = 'tx-locale' } = {}) {
27
+ return {
28
+ setLocale(req, res, locale) {
29
+ req.session[name] = locale;
30
+ },
31
+ getLocale(req) {
32
+ return req.session[name];
33
+ },
34
+ };
35
+ }
36
+
37
+ function noop() {}
38
+
39
+ class TxExpress {
40
+ constructor(options = {}) {
41
+ // https://www.kbairak.net/programming/python/2020/09/16/global-singleton-vs-instance-for-libraries.html
42
+ this.mode = CookieMode();
43
+ this.fallBackToAcceptLanguage = true;
44
+ this.sourceLocale = 'en';
45
+ this.daemon = true;
46
+ this.ttl = 10 * 60;
47
+ this.logging = noop;
48
+ this.setup(options);
49
+ }
50
+
51
+ setup({
52
+ mode,
53
+ fallBackToAcceptLanguage,
54
+ sourceLocale,
55
+ daemon,
56
+ ttl,
57
+ logging,
58
+ ...txOptions
59
+ } = {}) {
60
+ if (txOptions) {
61
+ tx.init(txOptions);
62
+ }
63
+ if (mode) {
64
+ this.mode = mode;
65
+ }
66
+ if (fallBackToAcceptLanguage !== undefined) {
67
+ this.fallBackToAcceptLanguage = fallBackToAcceptLanguage;
68
+ }
69
+ if (sourceLocale) {
70
+ this.sourceLocale = sourceLocale;
71
+ }
72
+ if (daemon !== undefined) {
73
+ this.daemon = daemon;
74
+ }
75
+ if (ttl) {
76
+ this.ttl = ttl;
77
+ }
78
+ if (logging) {
79
+ this.logging = logging;
80
+ }
81
+ }
82
+
83
+ middleware() {
84
+ return (req, res, next) => {
85
+ let locale = this.mode.getLocale(req, res);
86
+
87
+ if (
88
+ !locale
89
+ && this.fallBackToAcceptLanguage
90
+ && req.headers['accept-language']
91
+ ) {
92
+ // The header looks like: 'da, en-gb;q=0.8, en;'
93
+ // Locales without a 'q' value will be considered first, the rest will
94
+ // be sorted based on their q value. After sorting, we will use the
95
+ // first locale that is supported by Transifex Native.
96
+ const locales = req.headers['accept-language']
97
+ .split(',')
98
+ .map((section) => section.trim())
99
+ .map((section) => section.split(';'));
100
+ const localesWithoutQ = locales
101
+ .filter(([, q]) => !q)
102
+ .map(([code]) => code);
103
+ const localesWithQ = locales.filter(([, q]) => !!q)
104
+ .sort(([, left], [, right]) => (
105
+ parseFloat(right.substring(2)) - parseFloat(left.substring(2))
106
+ ))
107
+ .map(([code]) => code);
108
+ const finalLocales = localesWithoutQ.concat(localesWithQ);
109
+
110
+ for (let i = 0; i < finalLocales.length; i++) {
111
+ const current = finalLocales[i];
112
+ if (tx.locales.indexOf(current) !== -1) {
113
+ locale = current;
114
+ break;
115
+ }
116
+ }
117
+ }
118
+
119
+ if (!locale) { locale = this.sourceLocale; }
120
+
121
+ const ut = (...args) => tx.translateLocale(locale, ...args);
122
+ const t = (...args) => escape(ut(...args));
123
+
124
+ req.ut = ut;
125
+ req.t = t;
126
+
127
+ const oldRender = res.render.bind(res);
128
+ res.render = (view, locals, ...args) => {
129
+ const actualLocals = locals || {};
130
+ Object.assign(actualLocals, {
131
+ ut,
132
+ t,
133
+ tx: { languages: tx.languages, currentLocale: locale },
134
+ });
135
+ return oldRender(view, actualLocals, ...args);
136
+ };
137
+
138
+ next();
139
+ };
140
+ }
141
+
142
+ setLocale() {
143
+ return (req, res) => {
144
+ const locale = req.body.locale || this.sourceLocale;
145
+
146
+ this.mode.setLocale(req, res, locale);
147
+ if (typeis(req, ['json'])) {
148
+ res.json({ status: 'success' });
149
+ } else {
150
+ res.redirect(req.body.next || req.headers.referer);
151
+ }
152
+ };
153
+ }
154
+
155
+ async fetch() {
156
+ const _fetch = async () => {
157
+ await tx.getLocales();
158
+ for (let i = 0; i < tx.locales.length; i++) {
159
+ const locale = tx.locales[i];
160
+ /* eslint-disable no-await-in-loop */
161
+ await tx.fetchTranslations(locale);
162
+ /* eslint-enable */
163
+ }
164
+ };
165
+
166
+ this.logging('Transifex Native: fetching translations');
167
+ await _fetch();
168
+ this.logging('Transifex Native: done');
169
+ if (this.daemon) {
170
+ setInterval(_fetch, this.ttl * 1000);
171
+ }
172
+ }
173
+ }
174
+
175
+ module.exports = {
176
+ TxExpress,
177
+ CookieMode,
178
+ SignedCookieMode,
179
+ SessionMode,
180
+ };
@@ -0,0 +1,303 @@
1
+ /* globals jest test expect */
2
+
3
+ const typeis = require('type-is');
4
+
5
+ const { tx } = require('@transifex/native');
6
+
7
+ const {
8
+ TxExpress,
9
+ CookieMode,
10
+ SignedCookieMode,
11
+ SessionMode,
12
+ } = require('../src');
13
+
14
+ jest.mock('type-is');
15
+
16
+ test('TxExpress default values', () => {
17
+ const txExpress = new TxExpress();
18
+
19
+ // Ideally this should work but the functions are not considered equal and we
20
+ // have no data members to compare
21
+ // expect(txExpress.mode).toEqual(CookieMode());
22
+
23
+ expect(txExpress.fallBackToAcceptLanguage).toBe(true);
24
+ expect(txExpress.sourceLocale).toBe('en');
25
+ expect(txExpress.daemon).toBe(true);
26
+ expect(txExpress.ttl).toBe(10 * 60);
27
+ });
28
+
29
+ test('TxExpress custom values', () => {
30
+ const customOptions = {
31
+ mode: 'other',
32
+ fallBackToAcceptLanguage: false,
33
+ sourceLocale: 'other',
34
+ daemon: false,
35
+ ttl: 3,
36
+ logging: 'other',
37
+ };
38
+ let txExpress = new TxExpress(customOptions);
39
+ expect(txExpress).toEqual(customOptions);
40
+
41
+ txExpress = new TxExpress();
42
+ txExpress.setup(customOptions);
43
+ expect(txExpress).toEqual(customOptions);
44
+ });
45
+
46
+ test('Middleware with cookies', () => {
47
+ const languages = [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }];
48
+ tx.languages = languages;
49
+ tx.cache.update('fr', { foo: 'translation' });
50
+
51
+ const txExpress = new TxExpress({ mode: CookieMode({ name: 'tx-locale' }) });
52
+ const req = {
53
+ cookies: { 'tx-locale': 'fr' },
54
+ headers: {},
55
+ };
56
+ const res = {
57
+ render(view, locals) {
58
+ return [locals.t('foo', { _key: 'foo' }), locals.tx];
59
+ },
60
+ };
61
+ let nextCalled = false;
62
+ function next() { nextCalled = true; }
63
+
64
+ txExpress.middleware()(req, res, next);
65
+
66
+ expect(req.t('foo', { _key: 'foo' })).toBe('translation');
67
+ expect(res.render()).toEqual(['translation', { languages, currentLocale: 'fr' }]);
68
+ expect(nextCalled).toBe(true);
69
+ });
70
+
71
+ test('Middleware with signed cookies', () => {
72
+ const languages = [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }];
73
+ tx.languages = languages;
74
+ tx.cache.update('fr', { foo: 'translation' });
75
+
76
+ const txExpress = new TxExpress({ mode: SignedCookieMode({ name: 'tx-locale' }) });
77
+ const req = {
78
+ signedCookies: { 'tx-locale': 'fr' },
79
+ headers: {},
80
+ };
81
+ const res = {
82
+ render(view, locals) {
83
+ return [locals.t('foo', { _key: 'foo' }), locals.tx];
84
+ },
85
+ };
86
+ let nextCalled = false;
87
+ function next() { nextCalled = true; }
88
+
89
+ txExpress.middleware()(req, res, next);
90
+
91
+ expect(req.t('foo', { _key: 'foo' })).toBe('translation');
92
+ expect(res.render()).toEqual(['translation', { languages, currentLocale: 'fr' }]);
93
+ expect(nextCalled).toBe(true);
94
+ });
95
+
96
+ test('Middleware with sessions', () => {
97
+ const languages = [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }];
98
+ tx.languages = languages;
99
+ tx.cache.update('fr', { foo: 'translation' });
100
+
101
+ const txExpress = new TxExpress({ mode: SessionMode({ name: 'tx-locale' }) });
102
+ const req = {
103
+ session: { 'tx-locale': 'fr' },
104
+ headers: {},
105
+ };
106
+ const res = {
107
+ render(view, locals) {
108
+ return [locals.t('foo', { _key: 'foo' }), locals.tx];
109
+ },
110
+ };
111
+ let nextCalled = false;
112
+ function next() { nextCalled = true; }
113
+
114
+ txExpress.middleware()(req, res, next);
115
+
116
+ expect(req.t('foo', { _key: 'foo' })).toBe('translation');
117
+ expect(res.render()).toEqual(['translation', { languages, currentLocale: 'fr' }]);
118
+ expect(nextCalled).toBe(true);
119
+ });
120
+
121
+ test('Middleware falls back to header', () => {
122
+ const languages = [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }];
123
+ tx.languages = languages;
124
+ tx.locales = ['en', 'fr'];
125
+ tx.cache.update('fr', { foo: 'translation' });
126
+
127
+ const txExpress = new TxExpress({ mode: CookieMode({ name: 'tx-locale' }) });
128
+ const req = {
129
+ cookies: {},
130
+ headers: { 'accept-language': 'de, en;q=0.6, fr;q=0.8' },
131
+ };
132
+ const res = {
133
+ render(view, locals) {
134
+ return [locals.t('foo', { _key: 'foo' }), locals.tx];
135
+ },
136
+ };
137
+ let nextCalled = false;
138
+ function next() { nextCalled = true; }
139
+
140
+ txExpress.middleware()(req, res, next);
141
+
142
+ expect(req.t('foo', { _key: 'foo' })).toBe('translation');
143
+ expect(res.render()).toEqual(['translation', { languages, currentLocale: 'fr' }]);
144
+ expect(nextCalled).toBe(true);
145
+ });
146
+
147
+ test('setLocale with cookie and form with next', () => {
148
+ typeis.mockReturnValue(false);
149
+
150
+ const txExpress = new TxExpress({ mode: CookieMode({ name: 'tx-locale' }) });
151
+ const req = {
152
+ body: { locale: 'fr', next: 'next' },
153
+ };
154
+ let redirectTo = '';
155
+ const res = {
156
+ cookies: {},
157
+ cookie(key, value) { this.cookies[key] = value; },
158
+ redirect(path) { redirectTo = path; },
159
+ };
160
+
161
+ txExpress.setLocale()(req, res);
162
+
163
+ expect(res.cookies).toEqual({ 'tx-locale': 'fr' });
164
+ expect(redirectTo).toBe('next');
165
+ });
166
+
167
+ test('setLocale with cookie and form without next', () => {
168
+ typeis.mockReturnValue(false);
169
+
170
+ const txExpress = new TxExpress({ mode: CookieMode({ name: 'tx-locale' }) });
171
+ const req = {
172
+ body: { locale: 'fr' },
173
+ headers: { referer: 'referer' },
174
+ };
175
+ let redirectTo = '';
176
+ const res = {
177
+ cookies: {},
178
+ cookie(key, value) { this.cookies[key] = value; },
179
+ redirect(path) { redirectTo = path; },
180
+ };
181
+
182
+ txExpress.setLocale()(req, res);
183
+
184
+ expect(res.cookies).toEqual({ 'tx-locale': 'fr' });
185
+ expect(redirectTo).toBe('referer');
186
+ });
187
+
188
+ test('setLocale with cookie and cookie options and form with next', () => {
189
+ typeis.mockReturnValue(false);
190
+
191
+ const txExpress = new TxExpress({
192
+ mode: CookieMode({ name: 'tx-locale', cookieOptions: { some: 'option' } }),
193
+ });
194
+ const req = {
195
+ body: { locale: 'fr', next: 'next' },
196
+ };
197
+ let cookieOptions = {};
198
+ let redirectTo = '';
199
+ const res = {
200
+ cookies: {},
201
+ cookie(key, value, options) {
202
+ this.cookies[key] = value;
203
+ cookieOptions = options;
204
+ },
205
+ redirect(path) { redirectTo = path; },
206
+ };
207
+
208
+ txExpress.setLocale()(req, res);
209
+
210
+ expect(res.cookies).toEqual({ 'tx-locale': 'fr' });
211
+ expect(cookieOptions).toEqual({ some: 'option' });
212
+ expect(redirectTo).toBe('next');
213
+ });
214
+
215
+ test('setLocale with cookie and json', () => {
216
+ typeis.mockReturnValue(true);
217
+
218
+ const txExpress = new TxExpress({ mode: CookieMode({ name: 'tx-locale' }) });
219
+ const req = {
220
+ body: { locale: 'fr' },
221
+ };
222
+ let jsonResponse = {};
223
+ const res = {
224
+ cookies: {},
225
+ cookie(key, value) { this.cookies[key] = value; },
226
+ json(data) { jsonResponse = data; },
227
+ };
228
+
229
+ txExpress.setLocale()(req, res);
230
+
231
+ expect(res.cookies).toEqual({ 'tx-locale': 'fr' });
232
+ expect(jsonResponse).toEqual({ status: 'success' });
233
+ });
234
+
235
+ test('setLocale with signed cookie and form with next', () => {
236
+ typeis.mockReturnValue(false);
237
+
238
+ const txExpress = new TxExpress({ mode: SignedCookieMode({ name: 'tx-locale' }) });
239
+ const req = {
240
+ body: { locale: 'fr', next: 'next' },
241
+ };
242
+ let cookieOptions = {};
243
+ let redirectTo = '';
244
+ const res = {
245
+ cookies: {},
246
+ cookie(key, value, options) {
247
+ this.cookies[key] = value;
248
+ cookieOptions = options;
249
+ },
250
+ redirect(path) { redirectTo = path; },
251
+ };
252
+
253
+ txExpress.setLocale()(req, res);
254
+
255
+ expect(res.cookies).toEqual({ 'tx-locale': 'fr' });
256
+ expect(cookieOptions).toEqual({ signed: true });
257
+ expect(redirectTo).toBe('next');
258
+ });
259
+
260
+ test('setLocale with session and form with next', () => {
261
+ typeis.mockReturnValue(false);
262
+
263
+ const txExpress = new TxExpress({ mode: SessionMode({ name: 'tx-locale' }) });
264
+ const req = {
265
+ session: {},
266
+ body: { locale: 'fr', next: 'next' },
267
+ };
268
+ let redirectTo = '';
269
+ const res = {
270
+ redirect(path) { redirectTo = path; },
271
+ };
272
+
273
+ txExpress.setLocale()(req, res);
274
+
275
+ expect(req.session).toEqual({ 'tx-locale': 'fr' });
276
+ expect(redirectTo).toBe('next');
277
+ });
278
+
279
+ test('t vs ut', () => {
280
+ const languages = [{ code: 'en', name: 'English' }, { code: 'fr', name: 'French' }];
281
+ tx.languages = languages;
282
+ tx.cache.update('fr', { foo: 'A <rich>string</rich>' });
283
+
284
+ const txExpress = new TxExpress();
285
+ const req = {
286
+ cookies: { 'tx-locale': 'fr' },
287
+ headers: {},
288
+ };
289
+ const res = {
290
+ render(view, locals) {
291
+ return [locals.t('foo', { _key: 'foo' }), locals.ut('foo', { _key: 'foo' })];
292
+ },
293
+ };
294
+
295
+ txExpress.middleware()(req, res, () => {});
296
+
297
+ expect(req.t('foo', { _key: 'foo' })).toBe('A &lt;rich&gt;string&lt;/rich&gt;');
298
+ expect(req.ut('foo', { _key: 'foo' })).toBe('A <rich>string</rich>');
299
+ expect(res.render()).toEqual([
300
+ 'A &lt;rich&gt;string&lt;/rich&gt;',
301
+ 'A <rich>string</rich>',
302
+ ]);
303
+ });