1 |
|
2 |
|
3 |
|
4 |
|
5 |
|
6 |
|
7 |
|
8 |
|
9 |
|
10 |
|
11 | import {Locale} from 'locale';
|
12 |
|
13 | import {createPlugin, memoize, html} from 'fusion-core';
|
14 | import type {FusionPlugin, Context} from 'fusion-core';
|
15 | import {UniversalEventsToken} from 'fusion-plugin-universal-events';
|
16 | import bodyparser from 'koa-bodyparser';
|
17 | import querystring from 'querystring';
|
18 |
|
19 | import {I18nLoaderToken} from './tokens.js';
|
20 | import createLoader from './loader.js';
|
21 | import type {
|
22 | I18nDepsType,
|
23 | I18nServiceType,
|
24 | TranslationsObjectType,
|
25 | IEmitter,
|
26 | } from './types.js';
|
27 |
|
28 |
|
29 | export 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 |
|
41 |
|
42 |
|
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 |
|
54 | const matchIndex = translation.indexOf(literal, lastMatchIndex);
|
55 | if (matchIndex !== -1) {
|
56 | lastMatchIndex = matchIndex + literal.length;
|
57 | return true;
|
58 | }
|
59 | }
|
60 |
|
61 | return false;
|
62 | });
|
63 | };
|
64 | }
|
65 |
|
66 | function 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 |
|
84 | type PluginType = FusionPlugin<I18nDepsType, I18nServiceType>;
|
85 | const 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() {}
|
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 |
|
128 |
|
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 |
|
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 |
|
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 | `;
|
176 | ctx.template.body.push(script);
|
177 |
|
178 |
|
179 |
|
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');
|
207 | return next();
|
208 | } else {
|
209 | return next();
|
210 | }
|
211 | };
|
212 | },
|
213 | });
|
214 |
|
215 | export default ((__NODE__ && pluginFactory(): any): PluginType);
|