UNPKG

7.02 kBJavaScriptView Raw
1/** Copyright (c) 2018 Uber Technologies, Inc.
2 *
3 * This source code is licensed under the MIT license found in the
4 * LICENSE file in the root directory of this source tree.
5 *
6 * @flow
7 */
8
9/* eslint-env node */
10
11import {Locale} from 'locale';
12
13import {createPlugin, memoize, html} from 'fusion-core';
14import type {FusionPlugin, Context} from 'fusion-core';
15import {UniversalEventsToken} from 'fusion-plugin-universal-events';
16import bodyparser from 'koa-bodyparser';
17import querystring from 'querystring';
18
19import {I18nLoaderToken} from './tokens.js';
20import createLoader from './loader.js';
21import type {
22 I18nDepsType,
23 I18nServiceType,
24 TranslationsObjectType,
25 IEmitter,
26} from './types.js';
27
28// exported for testing
29export function matchesLiteralSections(literalSections: Array<string>) {
30 return (translation: string) => {
31 let lastMatchIndex = 0;
32
33 if (literalSections.length === 1) {
34 const literal = literalSections[0];
35 return literal !== '' && translation === literal;
36 }
37
38 return literalSections.every((literal, literalIndex) => {
39 if (literal === '') {
40 // literal section either:
41 // - starts/ends the literal
42 // - is the result of two adjacent interpolations
43 return true;
44 } else if (literalIndex === 0 && translation.startsWith(literal)) {
45 lastMatchIndex += literal.length;
46 return true;
47 } else if (
48 literalIndex === literalSections.length - 1 &&
49 translation.endsWith(literal)
50 ) {
51 return true;
52 } else {
53 // start search from `lastMatchIndex`
54 const matchIndex = translation.indexOf(literal, lastMatchIndex);
55 if (matchIndex !== -1) {
56 lastMatchIndex = matchIndex + literal.length;
57 return true;
58 }
59 }
60 // matching failed
61 return false;
62 });
63 };
64}
65
66function getKeysFromContext(ctx: Context): string[] {
67 if (ctx.request.body && Array.isArray(ctx.request.body)) {
68 return (ctx.request.body: any);
69 }
70
71 const querystringParams = querystring.parse(ctx.querystring);
72 if (querystringParams.keys) {
73 try {
74 const keys = JSON.parse(querystringParams.keys);
75 return Array.isArray(keys) ? keys : [];
76 } catch (e) {
77 return [];
78 }
79 }
80
81 return [];
82}
83
84type PluginType = FusionPlugin<I18nDepsType, I18nServiceType>;
85const pluginFactory: () => PluginType = () =>
86 createPlugin({
87 deps: {
88 loader: I18nLoaderToken.optional,
89 events: UniversalEventsToken.optional,
90 },
91 provides: ({loader, events}) => {
92 class I18n {
93 translations: TranslationsObjectType;
94 locale: string | Locale;
95 emitter: ?IEmitter;
96
97 constructor(ctx) {
98 if (!loader) {
99 loader = createLoader();
100 }
101 const {translations, locale} = loader.from(ctx);
102 this.emitter = events && events.from(ctx);
103 this.translations = translations;
104 this.locale = locale;
105 }
106 async load() {} //mirror client API
107 translate(key, interpolations = {}) {
108 const template = this.translations[key];
109
110 if (typeof template !== 'string') {
111 this.emitter && this.emitter.emit('i18n-translate-miss', {key});
112 return key;
113 }
114
115 return template.replace(/\${(.*?)}/g, (_, k) =>
116 interpolations[k] === void 0
117 ? '${' + k + '}'
118 : String(interpolations[k])
119 );
120 }
121 }
122
123 const service = {from: memoize(ctx => new I18n(ctx))};
124 return service;
125 },
126 middleware: (_, plugin) => {
127 // TODO(#4) refactor: this currently depends on babel plugins in framework's webpack config.
128 // Ideally these babel plugins should be part of this package, not hard-coded in framework core
129 const chunkTranslationMap = require('../chunk-translation-map');
130 const parseBody = bodyparser();
131
132 return async (ctx, next) => {
133 if (ctx.element) {
134 await next();
135 const i18n = plugin.from(ctx);
136
137 // get the webpack chunks that are used and serialize their translations
138 const chunks: Array<string | number> = [
139 ...ctx.syncChunks,
140 ...ctx.preloadChunks,
141 ];
142 const translations = {};
143 const possibleTranslations = i18n.translations
144 ? Object.keys(i18n.translations)
145 : [];
146 chunks.forEach(id => {
147 const keys = Array.from(
148 chunkTranslationMap.translationsForChunk(id)
149 );
150 keys.forEach(key => {
151 if (Array.isArray(key)) {
152 const matches = possibleTranslations.filter(
153 matchesLiteralSections(key)
154 );
155 for (const match of matches) {
156 translations[match] =
157 i18n.translations && i18n.translations[match];
158 }
159 } else {
160 translations[key] = i18n.translations && i18n.translations[key];
161 }
162 });
163 });
164 // i18n.locale is actually a locale.Locale instance
165 if (!i18n.locale) {
166 throw new Error('i18n.locale was empty');
167 }
168 const localeCode =
169 typeof i18n.locale === 'string' ? i18n.locale : i18n.locale.code;
170 const serialized = JSON.stringify({localeCode, translations});
171 const script = html`
172 <script type="application/json" id="__TRANSLATIONS__">
173 ${serialized}
174 </script>
175 `; // consumed by ./browser
176 ctx.template.body.push(script);
177
178 // set HTML lang tag as a hint for signal screen readers to switch to the
179 // recommended language.
180 ctx.template.htmlAttrs.lang = localeCode;
181 } else if (ctx.path === '/_translations') {
182 const i18n = plugin.from(ctx);
183 try {
184 await parseBody(ctx, () => Promise.resolve());
185 } catch (e) {
186 ctx.request.body = [];
187 }
188 const keys = getKeysFromContext(ctx);
189 const possibleTranslations = i18n.translations
190 ? Object.keys(i18n.translations)
191 : [];
192 const translations = keys.reduce((acc, key) => {
193 if (Array.isArray(key)) {
194 const matches = possibleTranslations.filter(
195 matchesLiteralSections(key)
196 );
197 for (const match of matches) {
198 acc[match] = i18n.translations && i18n.translations[match];
199 }
200 } else {
201 acc[key] = i18n.translations && i18n.translations[key];
202 }
203 return acc;
204 }, {});
205 ctx.body = translations;
206 ctx.set('cache-control', 'public, max-age=3600'); // cache translations for up to 1 hour
207 return next();
208 } else {
209 return next();
210 }
211 };
212 },
213 });
214
215export default ((__NODE__ && pluginFactory(): any): PluginType);