UNPKG

apostrophe

Version:
1,275 lines (1,239 loc) 53.5 kB
// This module makes an instance of the [i18next](https://npmjs.org/package/i18next) npm module available // in Nunjucks templates via the `__t()` helper function. That function is also // available on `req` objects as `req.t()`. Any options passed to this module // are passed on to `i18next`. // // `apos.i18n.i18next` can be used to directly access the `i18next` npm module // instance if necessary. It usually is not necessary. Use `req.t` if you need // to localize in a route. // // ## Options // // ### `locales` TODO // // ### `defaultLocale` TODO // // ### `adminLocales` // // Controls what admin UI language can be set per user. If set, `adminLocale` // user field will be automatically added to the user schema. Contains an array // of objects with `label` and `value` properties: ```js { label: 'English', // value: 'en' } ``` // // ### `defaultAdminLocale` // // The default admin UI language. If `adminLocales` are configured, it should // should match a `value` property from the list. Furthermore, it will be used // as the default value for the`adminLocale` user field. If it is not set, // but `adminLocales` is set, then the default is to display the admin UI // in the same language as the website content. // Example: `defaultAdminLocale: 'fr'`. // // ### `encoding` // // Defaults to `'utf-8'`. You almost certainly do not want to change this. // // ### `slugDirection` // // Controls the default `direction` value of slug schema. Can be `ltr`, `rtl` or // `undefined|null` to not set a default. Defaults to `ltr`. const i18next = require('i18next'); const fs = require('fs'); const _ = require('lodash'); const { stripIndent } = require('common-tags'); const ExpressSessionCookie = require('express-session/session/cookie'); const path = require('path'); const { verifyLocales } = require('../../../lib/locales'); const apostropheI18nDebugPlugin = { type: 'postProcessor', name: 'apostropheI18nDebugPlugin', process(value, key, options, translator) { // The key is passed as an array (theoretically to include multiple keys). // We confirm that and grab the primary one for comparison. const l10nKey = Array.isArray(key) ? key[0] : key; if (value === l10nKey) { if (l10nKey.match(/^\S+:/)) { // The l10n key does not have a value assigned (or the key is // actually the same as the phrase). The key seems to have a // namespace, so might be from the Apostrophe UI. return `❌ ${value}`; } else { // The l10n key does not have a value assigned (or the key is // actually the same as the phrase). It is in the default namespace. return `🕳 ${value}`; } } else { // The phrase is fully localized. return `🌍 ${value}`; } } }; module.exports = { options: { alias: 'i18n', i18n: { ns: 'apostrophe', browser: true }, // If true, slugifying will strip accents from Latin characters stripUrlAccents: false, // You almost certainly do not want to change this encoding: 'utf-8', slugDirection: 'ltr' }, async init(self) { self.defaultNamespace = 'default'; self.namespaces = {}; self.debug = process.env.APOS_DEBUG_I18N ? true : self.options.debug; self.show = process.env.APOS_SHOW_I18N ? true : self.options.show; self.locales = self.getLocales(); self.hostnamesInUse = Object.values(self.locales).find(locale => locale.hostname); self.defaultLocale = self.options.defaultLocale || Object.keys(self.locales)[0]; // Contains label/value object for each locale self.adminLocales = self.options.adminLocales || []; // Contains only the string value of the default admin locale (e.g. 'en'). // If adminLocales are configured, it should be one of them. Otherwise, // it can be any valid locale string identifier. self.defaultAdminLocale = self.options.defaultAdminLocale || null; if (self.options.slugDirection && ![ 'ltr', 'rtl' ].includes(self.options.slugDirection)) { throw self.apos.error( 'invalid', `The "slugDirection" option of "${self.__meta.name}" module must be "ltr", "rtl" or "null".` ); } // Lint the locale configurations for (const [ key, options ] of Object.entries(self.locales)) { if (!options) { throw self.apos.error('invalid', `Locale "${key}" was not configured.`); } if (typeof key !== 'string' || !key.match(/^[a-zA-Z]/)) { throw self.apos.error('invalid', `Locale names must begin with a non-numeric, "word" character (a-z or A-Z). Check locale "${key}".`); } if (options.prefix && !options.prefix.match(/^\//)) { throw self.apos.error('invalid', `Locale prefixes must begin with a forward slash ("/"). Check locale "${key}".`); } if (options.prefix && options.prefix.match(/\/.*?\//)) { throw self.apos.error('invalid', `Locale prefixes must not contain more than one forward slash ("/").\nUse hyphens as separators. Check locale "${key}".`); } } if (!Array.isArray(self.adminLocales)) { throw self.apos.error('invalid', 'The "adminLocales" option must be an array.'); } if (self.defaultAdminLocale && typeof self.defaultAdminLocale !== 'string') { throw self.apos.error('invalid', 'The "defaultAdminLocale" option must be a string.'); } if ( self.defaultAdminLocale && self.adminLocales.length && !self.adminLocales.some(al => al.value === self.defaultAdminLocale) ) { throw self.apos.error('invalid', `The value of "defaultAdminLocale" "${self.defaultAdminLocale}" doesn't match any of the existing "adminLocales" values.`); } const fallbackLng = [ self.defaultLocale ]; // In case the default locale also has inadequate admin UI phrases if (fallbackLng[0] !== 'en') { fallbackLng.push('en'); } // Make sure we have our own instance to avoid conflicts with other apos // objects self.i18next = i18next.createInstance({ fallbackLng, // Required to prevent the debugger from complaining languages: Object.keys(self.locales), // Added later, but required here resources: {}, interpolation: { // Nunjucks and Vue will already do this escapeValue: false }, defaultNS: self.defaultNamespace, debug: self.debug }); if (self.show) { self.i18next.use(apostropheI18nDebugPlugin); } const i18nextOptions = self.show ? { postProcess: 'apostropheI18nDebugPlugin' } : {}; await self.i18next.init(i18nextOptions); self.addInitialResources(); self.enableBrowserData(); self.encoding = self.options.encoding; }, handlers(self) { return { 'apostrophe:modulesRegistered': { addModal() { self.addLocalizeModal(); } }, '@apostrophecms/page:beforeSend': { // Developers can link to alternate locales by iterating over // `data.localizations` in any page template. Each element always has // `locale`, `label` and `homePageUrl` properties. Each element also // has an `available` property; if true, the current context document is // available in that locale, `title` and a small number of other // document properties are populated, and `_url` redirects to the // context document in that locale. // // The array is provided in the order in which locales are configured. // The current locale is included and has the property `current: true`. async addLocalizations(req) { const locale = req.locale || self.defaultLocale; req.data.i18n = { locale, direction: self.locales[locale]?.direction || 'ltr', label: self.locales[locale]?.label || locale }; const context = req.data.piece || req.data.page; if (!context) { return; } const manager = self.apos.doc.getManager(context.type); if (!manager.isLocalized()) { return; } const localizations = await self.apos.doc.db.find({ aposDocId: context.aposDocId, aposMode: req.mode }).project({ type: 1, title: 1, slug: 1, aposLocale: 1, aposMode: 1, visibility: 1, docPermissions: 1 }).toArray(); req.data.localizations = []; for (const name of Object.keys(self.locales)) { const localeReq = self.apos.util.cloneReq(req, { locale: name }); self.setPrefixUrls(localeReq); const doc = localizations.find(doc => doc.aposLocale.split(':')[0] === name); if (doc && self.apos.permission.can(req, 'view', doc)) { doc.available = true; // WARNING: the `addUrls` call below has a serious // performance impact (extra DB queries per locale). // Keep this gated for static builds only. if (self.apos.url.isExternalFront(req) && self.apos.url.options.static) { // Static builds can't follow the API redirect route // (there is no server to handle it), so compute the // real URL using the same mechanisms the query builders // would. if (self.apos.page.isPage(doc)) { doc._url = `${localeReq.prefix}${doc.slug}`; } else if (manager.addUrls) { await manager.addUrls(localeReq, [ doc ]); } } // Fall back to the API redirect route when no real URL // was resolved (e.g. traditional Nunjucks frontend, non static // external frontend). if (!doc._url) { doc._url = `${self.apos.prefix}${manager.action}/${context._id}/locale/${name}`; } if (doc._id === context._id) { doc.current = true; } } const info = doc || {}; info.locale = name; info.label = self.locales[name].label; info.direction = self.locales[name].direction; info.homePageUrl = `${localeReq.prefix}/`; req.data.localizations.push(info); } } } }; }, middleware(self) { return { async acceptCrossDomainSessionToken(req, res, next) { let crossDomainSessionToken = req.query.aposCrossDomainSessionToken; if (!crossDomainSessionToken) { return next(); } crossDomainSessionToken = self.apos.launder.string(crossDomainSessionToken); try { const sessionData = await self.apos.cache.get('@apostrophecms/i18n:cross-domain-sessions', crossDomainSessionToken); for (const key of Object.keys(req.session)) { delete req.session[key]; } Object.assign(req.session, sessionData || {}); await self.apos.cache.set('@apostrophecms/i18n:cross-domain-sessions', crossDomainSessionToken, null); } catch (e) { self.apos.util.error(e); } // Since the req.session object at this stage is just // a plain Javascript object, getters and setters of // the Cookie prototype from the express-session module // (like req.session.cookie.data) are unavailable and // will return 'undefined' when called internally by the // express-session module. This ends up corrupting the // set-cookie headers generated by the express-session // module, thus breaking sessions. The express-session // module normally generates an instance of its Cookie // prototype which contains these methods and expects // that interface always to function properly. To ensure // this, we re-instantiate req.session.cookie as an // instance of the Cookie class from the express-session // module to ensure that the sessions remain intact even // across domains. Note that we use the cookie settings // from the Apostrophe Express module to ensure that user // defined cookie settings are respected. const aposExpressModule = self.apos.modules['@apostrophecms/express']; req.session.cookie = new ExpressSessionCookie( aposExpressModule.sessionOptions.cookie ); return res.redirect( self.apos.url.build(req.url, { aposCrossDomainSessionToken: null }) ); }, // If the `redirectToFirstLocale` option is enabled // and the homepage is requested, // redirects to the first locale configured with the // current requested hostname when all of the locales // configured with that hostname do have a prefix. // // However, if the request does not match any explicit // hostnames assigned to locales, redirects to the first // locale that does not have a configured hostname, if // all the locales without a hostname do have a prefix. redirectToFirstLocale(req, res, next) { if (!self.options.redirectToFirstLocale) { return next(); } if (req.path !== '' && req.path !== '/') { return next(); } const locales = Object.values( self.filterPrivateLocales(req, self.locales) ); const localesWithoutHostname = locales.filter( locale => !locale.hostname ); const localesWithCurrentHostname = locales.filter( locale => locale.hostname && locale.hostname.split(':')[0] === req.hostname ); const localesToCheck = localesWithCurrentHostname.length ? localesWithCurrentHostname : localesWithoutHostname; if (!localesToCheck.length || !localesToCheck.every(locale => locale.prefix)) { return next(); } // Add / for home page and to avoid being redirected again in // the `locale` middleware: const redirectUrl = `${localesToCheck[0].prefix}/`; return res.redirect(redirectUrl); }, locale(req, res, next) { // Support for a single aposLocale query param that // also contains the mode, which is likely to occur // since we have the `aposLocale` property in docs // structured that way if (req.query.aposLocale && req.query.aposLocale.includes(':')) { const parts = req.query.aposLocale.split(':'); req.query.aposLocale = parts[0]; req.query.aposMode = parts[1]; } const validModes = [ 'draft', 'published' ]; let locale; if (self.isValidLocale(req.query.aposLocale)) { locale = req.query.aposLocale; } else { locale = self.matchLocale(req); } const locales = self.filterPrivateLocales(req, self.locales); const localeOptions = locales[locale]; if (localeOptions.prefix) { // Remove locale prefix so URL parsing can proceed normally from here if (req.path === localeOptions.prefix) { // Add / for home page req.redirect = `${req.url}/`; } if (req.path.substring(0, localeOptions.prefix.length + 1) === localeOptions.prefix + '/') { req.path = req.path.replace(localeOptions.prefix, ''); req.url = req.url.replace(localeOptions.prefix, ''); const superRedirect = res.redirect; res.redirect = function (status, url) { if (arguments.length === 1) { url = status; status = 302; } if (!url.match(/^[a-zA-Z]+:/)) { // We don't need all of req.prefix here because // the global site prefix middleware already extended // res.redirect once url = localeOptions.prefix + url; } return superRedirect.call(this, status, url); }; } } let mode; if (validModes.includes(req.query.aposMode)) { mode = req.query.aposMode; } else { mode = 'published'; } req.locale = locale; req.mode = mode; self.setPrefixUrls(req); if ((req.mode === 'draft') && (!self.apos.permission.can(req, 'view-draft'))) { return res.status(403).send({ name: 'forbidden' }); } _.defaults(req.data, _.pick(req, 'baseUrl', 'baseUrlWithPrefix', 'absoluteUrl')); return next(); }, localize(req, res, next) { req.t = (key, options = {}) => { return self.i18next.t(key, { ...options, lng: req.locale }); }; req.__ = key => { self.apos.util.warnDevOnce('old-i18n-req-helper', stripIndent` The req.__() and res.__() functions are deprecated and do not localize in A3. Use req.t instead. `); return key; }; req.res.__ = req.__; return next(); } }; }, apiRoutes(self) { return { get: { locales(req) { return self.locales; }, async localesPermissions(req) { const action = self.apos.launder.string(req.query.action); const type = self.apos.launder.string(req.query.type); const locales = self.apos.launder.strings(req.query.locales); const allowed = await self.getLocalesPermissions(req, action, type, locales); return allowed; } }, post: { async locale(req) { const sanitizedLocale = self.sanitizeLocaleName(req.body.locale); if (!sanitizedLocale) { throw self.apos.error('invalid', 'invalid locale'); } // Clipboards transferring between locales needs to jump // from LocalStorage to the cross-domain session cache let clipboard = req.body.clipboard; if (clipboard && ((typeof clipboard) !== 'string')) { // Clipboard re-validation doesn't have to be more detailed here // because on any actual paste attempt it will go through server // side validation like any normal insert of a widget clipboard = null; } const _id = self.apos.launder.id(req.body.contextDocId); let doc; const localeReq = req.clone({ locale: sanitizedLocale }); if (_id) { const aposDocId = _id.split(':')[0]; doc = await self.apos.doc.find(localeReq, { aposDocId }).toObject(); if (!doc?._url) { const draftLocaleReq = localeReq.clone({ mode: 'draft' }); doc = await self.apos.doc.find(draftLocaleReq, { aposDocId }).toObject(); } } if (!sanitizedLocale) { throw self.apos.error('invalid'); } const result = {}; if (doc && doc._url) { result.redirectTo = doc && doc._url; } else { // No matching document, so as a fallback go to the home page // with the appropriate prefix result.redirectTo = localeReq.prefix; }; if ( self.locales[localeReq.locale].hostname !== self.locales[req.locale].hostname ) { const crossDomainSessionToken = self.apos.util.generateId(); const session = { ...req.session, aposCrossDomainClipboard: clipboard }; await self.apos.cache.set('@apostrophecms/i18n:cross-domain-sessions', crossDomainSessionToken, session, 60 * 60); result.redirectTo = self.apos.url.build(result.redirectTo, { aposCrossDomainSessionToken: crossDomainSessionToken }); } return result; }, // Fast bulk query for doc `ids` that exist in the given `locale`. // `ids` may contain `_id` or `aposDocId` values. // // The response object contains `originalLocaleIds`, `newLocaleIds` and // `aposDocIds` arrays. Any documents not existing in `locale` // will not be included in these arrays. // // The original mode and locale are inferred from the given // `ids`, or from the request. // // This route is a POST route because large numbers of ids // might not be accepted as a query string. async existInLocale(req) { if (!req.user) { throw self.apos.error('notfound'); } const ids = self.apos.launder.ids(req.body.ids); const locale = self.apos.launder.string(req.body.locale); const originalLocale = (ids[0] && ids[0].split(':')[1]) || req.locale; const originalMode = (ids[0] && ids[0].split(':')[2]) || req.mode; const mode = self.apos.launder.string(req.body.mode, originalMode); if (!self.isValidLocale(locale)) { throw self.apos.error('invalid'); } const found = await self.apos.doc.db.find({ aposLocale: `${locale}:${mode}`, aposDocId: { $in: ids.map(self.apos.doc.toAposDocId) } }).project({ _id: 1, aposDocId: 1 }).toArray(); const result = { originalLocaleIds: found.map(doc => `${doc.aposDocId}:${originalLocale}:${originalMode}`), newLocaleIds: found.map(doc => doc._id), aposDocIds: found.map(doc => doc.aposDocId) }; return result; } } }; }, methods(self) { return { // Add the i18next resources provided by the specified module, // merging with any existing phrases for the same locales and namespaces addResourcesForModule(module) { self.addDefaultResourcesForModule(module); self.addNamespacedResourcesForModule(module); }, // Automatically adds any localizations found in .json files in // the main `i18n` subdirectory of a module. // // These are added to the `default` namespace, unless the legacy // `i18n.ns` option is set for the module (not the preferred way, use // namespace subdirectories in new projects). addDefaultResourcesForModule(module) { const ns = (module.options.i18n && module.options.i18n.ns) || 'default'; self.namespaces[ns] = self.namespaces[ns] || {}; self.namespaces[ns].browser = self.namespaces[ns].browser || (module.options.i18n && module.options.i18n.browser); for (const entry of module.__meta.chain) { const localizationsDir = path.join(entry.dirname, 'i18n'); if (!self.defaultLocalizationsDirsAdded.has(localizationsDir)) { self.defaultLocalizationsDirsAdded.add(localizationsDir); if (!fs.existsSync(localizationsDir)) { continue; } for (const localizationFile of fs.readdirSync(localizationsDir)) { if (!localizationFile.endsWith('.json')) { // Likely a namespace subdirectory continue; } const data = JSON.parse( fs.readFileSync(path.join(localizationsDir, localizationFile)) ); const locale = localizationFile.replace('.json', ''); self.i18next.addResourceBundle(locale, ns, data, true, true); } } } }, // Automatically adds any localizations found in subdirectories of the // main `i18n` subdirectory of a module. The subdirectory's name is // treated as an i18n namespace name. addNamespacedResourcesForModule(module) { for (const entry of module.__meta.chain) { const metadata = module.__meta.i18n[entry.name] || {}; const localizationsDir = `${entry.dirname}/i18n`; if (!self.namespacedLocalizationsDirsAdded.has(localizationsDir)) { self.namespacedLocalizationsDirsAdded.add(localizationsDir); if (!fs.existsSync(localizationsDir)) { continue; } for (const ns of fs.readdirSync(localizationsDir)) { if (ns.endsWith('.json')) { // A JSON file for the default namespace, already handled continue; } self.namespaces[ns] = self.namespaces[ns] || {}; self.namespaces[ns].browser = self.namespaces[ns].browser || (metadata[ns] && metadata[ns].browser); const namespaceDir = path.join(localizationsDir, ns); if (!fs.statSync(namespaceDir).isDirectory()) { // Skip non-directory items, such as hidden files continue; } for (const localizationFile of fs.readdirSync(namespaceDir)) { if (!localizationFile.endsWith('.json')) { // Exclude parsing of non-JSON files, like hidden files, // in the namespace directory continue; } const fullLocalizationFile = path.join(namespaceDir, localizationFile); const data = JSON.parse(fs.readFileSync(fullLocalizationFile)); const locale = localizationFile.replace('.json', ''); self.i18next.addResourceBundle(locale, ns, data, true, true); } } } } }, // Adds i18next resources for modules initialized before the i18n module // itself, called by init. Later modules call addResourcesForModule(self), // making phrases available gradually as Apostrophe starts up addInitialResources() { self.defaultLocalizationsDirsAdded = new Set(); self.namespacedLocalizationsDirsAdded = new Set(); for (const module of Object.values(self.apos.modules)) { self.addResourcesForModule(module); } }, isValidLocale(locale) { return locale && has(self.locales, locale); }, // Return the best matching locale for the request based on the hostname // and path prefix. If available the first locale matching both // hostname and prefix is returned, otherwise the first matching locale // that specifies only a hostname or only a prefix. If no matches are // possible the default locale is returned. matchLocale(req) { const hostname = req.hostname; const locales = self.filterPrivateLocales(req, self.locales); let best = false; for (const [ name, options ] of Object.entries(locales)) { const matchedHostname = options.hostname ? (hostname === options.hostname.split(':')[0]) : null; const matchedPrefix = options.prefix ? ((req.path === options.prefix) || req.path.startsWith(options.prefix + '/')) : null; if (options.hostname && options.prefix) { if (matchedHostname && matchedPrefix) { // Best possible match return name; } } else if (options.hostname) { if (matchedHostname) { if (!best) { best = name; } } } else if (options.prefix) { if (matchedPrefix) { if (!best) { best = name; } } } } return best || self.defaultLocale; }, // Infer `req.locale` and `req.mode` from `_id` if they were // not set already by explicit query parameters. Conversely, // if the appropriate query parameters were set, rewrite // `_id` accordingly. Returns `_id`, after rewriting if appropriate. inferIdLocaleAndMode(req, _id) { let [ id, locale, mode ] = _id.split(':'); if (locale && mode) { if (!req.query.aposLocale) { req.locale = locale; } else { locale = req.locale; } if (!req.query.aposMode) { req.mode = mode; } else { mode = req.mode; } } else { // aposDocId was passed, complete the _id from whatever // was in query params or defaults locale = req.locale; mode = req.mode; } if ((req.mode === 'draft') && (!self.apos.permission.can(req, 'view-draft'))) { throw self.apos.error('forbidden'); } if (_id.charAt(0) === '_') { // A shortcut such as _home or _archive, // will be interpreted later return _id; } else { return `${id}:${locale}:${mode}`; } }, getBrowserData(req) { const adminLocale = req.user?.adminLocale === '' ? req.locale : req.user?.adminLocale || self.defaultAdminLocale || req.locale; const i18n = { [adminLocale]: self.getBrowserBundles(adminLocale) }; if (adminLocale !== self.defaultLocale) { i18n[self.defaultLocale] = self.getBrowserBundles(self.defaultLocale); } // In case the default locale also has inadequate admin UI phrases if (!i18n.en) { i18n.en = self.getBrowserBundles('en'); } const result = { i18n, locale: req.locale, adminLocale, defaultLocale: self.defaultLocale, defaultNamespace: self.defaultNamespace, locales: self.locales, debug: self.debug, show: self.show, action: self.action, crossDomainClipboard: req.session && req.session.aposCrossDomainClipboard, stripUrlAccents: self.options.stripUrlAccents, slugDirection: self.options.slugDirection }; if (req.session && req.session.aposCrossDomainClipboard) { req.session.aposCrossDomainClipboard = null; } return result; }, getBrowserBundles(locale) { const i18n = {}; for (const [ name, options ] of Object.entries(self.namespaces)) { if (options.browser) { i18n[name] = self.i18next.getResourceBundle(locale, name); if (!i18n[name]) { // Attempt fallback to language only. This is not // the full fallback support of i18next because that // is difficult to tap into when calling getResourceBundle, // but it should work for most situations const [ lang, country ] = locale.split('-'); if (country) { i18n[name] = self.i18next.getResourceBundle(lang, name); } } } } return i18n; }, getLocales() { const locales = self.options.locales || { en: { label: 'English', direction: 'ltr' } }; for (const locale in locales) { locales[locale]._edit = true; locales[locale].direction ??= 'ltr'; } verifyLocales(locales, self.apos.options.baseUrl); return locales; }, async getLocalesPermissions(req, action, type, locales) { const allowed = []; for (const locale of locales) { const clonedReq = req.clone({ locale }); if (await self.apos.permission.can(clonedReq, action, type)) { allowed.push(locale); } } return allowed; }, sanitizeLocaleName(locale) { locale = self.apos.launder.string(locale); if (!has(self.locales, locale)) { return null; } return locale; }, shouldStripAccents() { return self.options.stripUrlAccents === true; }, addLocalizeModal() { self.apos.modal.add( `${self.__meta.name}:localize`, self.getComponentName('localizeModal', 'AposI18nLocalize'), { moduleName: self.__meta.name } ); }, setPrefixUrls(req) { // In a production-like environment, use req.hostname, otherwise the // Host header to allow port numbers in dev. // // Watch out for modules that won't be set up if this is an afterInit // task in an early module like the asset module const host = (process.env.NODE_ENV === 'production') ? req.hostname : req.get('Host'); const fallbackBaseUrl = `${req.protocol}://${host}`; if (self.hostnamesInUse) { req.baseUrl = (self.apos.page && self.apos.page.getBaseUrl(req)) || fallbackBaseUrl; } else { req.baseUrl = self.apos.page && self.apos.page.getBaseUrl(req); } req.baseUrlWithPrefix = `${req.baseUrl}${self.apos.prefix}`; req.absoluteUrl = req.baseUrlWithPrefix + req.url; req.prefix = `${req.baseUrlWithPrefix}${self.locales[req.locale].prefix || ''}`; if (!req.baseUrl) { // Always set for bc, but in the absence of locale hostnames we // set it later so it is not part of req.prefix req.baseUrl = fallbackBaseUrl; } }, // Returns an Express route suitable for use in a module // like a piece type or the page module. The returned route will // expect req.params._id and req.params.toLocale and redirect, // if possible, to the corresponding version in toLocale. toLocaleRouteFactory(module) { return async (req, res) => { try { const _id = module.inferIdLocaleAndMode(req, req.params._id); const toLocale = self.sanitizeLocaleName(req.params.toLocale); if (!toLocale) { return res.status(400).send('invalid locale name'); } const localeReq = req.clone({ locale: toLocale }); const corresponding = await module.find(localeReq, { _id: `${_id.split(':')[0]}:${localeReq.locale}:${localeReq.mode}` }).toObject(); if (!corresponding) { return res.status(404).send('not found'); } if (!corresponding._url) { return res.status(400).send('invalid (has no URL)'); } return res.redirect(corresponding._url); } catch (e) { self.apos.util.error(e); return res.status(500).send('error'); } }; }, // Exclude private locales when logged out filterPrivateLocales(req, locales) { return req.user ? locales : Object.fromEntries( Object .entries(locales) .filter(([ name, options ]) => options.private !== true) ); }, // Rename a locale. This is time consuming and should be // avoided when possible. If `keep` is present it must be set // to either `oldLocale` or `newLocale` and indicates which version // is kept in the event of a conflict async rename(oldLocale, newLocale, { keep } = {}) { let renamed = 0; let kept = 0; if (!oldLocale) { throw new Error('You must specify --old'); } if (!newLocale) { throw new Error('You must specify --new'); } if (oldLocale === newLocale) { throw new Error('The old and new locales must be different'); } if (keep && (!(keep === oldLocale) && !(keep === newLocale))) { throw new Error('--keep must match --old or --new'); } const ids = await self.apos.doc.db.find({ aposLocale: new RegExp(`^${self.apos.util.regExpQuote(oldLocale)}:`) }).project({ _id: 1 }).toArray(); ({ renamed, kept } = await self.apos.doc.changeDocIds(ids.map(doc => [ doc._id, doc._id.replace(`:${oldLocale}`, `:${newLocale}`) ]), { keep: (keep === oldLocale) ? 'old' : (keep === newLocale) ? 'new' : false })); return { renamed, kept }; }, // Localize a batch of documents. // // The `req.body` object must have properties // - `_ids`: an array of document `_id` values. // - `relatedTypes`: an array of related doc types to be localized in case // they are found in the batch of documents to localize. // - `toLocales`: an array of locales to localize the documents to. // - `update`: a boolean indicating whether to localize existing related // documents. - `relatedOnly`: a boolean indicating whether to only // localize related documents and skip the parent documents (`_ids`). // // Automatic translation instructions may be included in the `req.query` // object: - `aposTranslateProvider`: the unique name of the translation // provider. - `aposLocale`: the locale to translate from. Note that // without these instructions, the signal to the automatic translation // service will not be sent. // // `manager` is the `self` object of the module that is localizing the // documents. If the batch is a set of pages, `manager` should be an // instance of `@apostrophecms/page`. For pieces, `manager` should be an // instance of the piece type module. `reporting` is an optional object // that can be used to report progress. See the `@apostrophecms/job` // module for more information. // // The handler will return a log, array of objects with the following // properties: - `id`: the document `_id` value - `aposId`: the document // `aposDocId` value - `type`: the document type, can be `null` if the // document is not found - `title`: the document title, can be `null` if // the document is not found - `relationship`: the `aposDocId` of the // parent document, or `false` if the document is the parent. - `error`: a // boolean or string `reason` indicating whether an error occurred during // localization. If `error` is a string, it will contain the error name. // See `@apostrophecms/error` and `@apostrophecms/http` modules. - // `detail`: optional string (i18n key) explaining the error. async localizeBatch(req, manager, reporting = null) { if (!req.user) { throw self.apos.error('forbidden'); } if (!Array.isArray(req.body._ids)) { throw self.apos.error('invalid'); } if (!Array.isArray(req.body.toLocales)) { throw self.apos.error('invalid'); } const ids = self.apos.launder.ids(req.body._ids) .map(id => self.inferIdLocaleAndMode(req, id)); if (reporting) { reporting.setTotal(ids.length); } const toLocales = self.apos.launder.strings(req.body.toLocales) .filter(toLocale => !!self.sanitizeLocaleName(toLocale)); const update = self.apos.launder.boolean(req.body.update); const relatedTypes = new Set( self.apos.launder.strings(req.body.relatedTypes) ); normalizeTypes(relatedTypes); const relatedOnly = self.apos.launder.boolean(req.body.relatedOnly); // Result log used for batch reporting const log = []; // Global set to avoid duplicate processing const seen = new Set(); for (const id of ids) { let doc; try { [ doc ] = await getDocs(req, manager, { ids: [ id ] }); } catch (e) { logMissing(id, log, reporting); self.logError( req, 'localize-batch-doc-error', 'Error finding document', { id, error: e.message, stack: e.stack.split('\n').slice(1).map(line => line.trim()) } ); continue; } await localizeDoc( req, reporting, { doc, relatedTypes, toLocales, update, relatedOnly, log, seen } ); } if (reporting) { reporting.setResults({ log, ids }); } self.logDebug(req, 'localize-batch-result', 'Batch localization complete', { log, ids }); return log; // Convert the "any page types" to actual page types async function normalizeTypes(types) { if (types.has('@apostrophecms/page') || types.has('@apostrphecms/any-page-type')) { self.apos.instancesOf('@apostrophecms/page-type') .map(module => module.__meta.name) .forEach(type => types.add(type)); types.delete('@apostrophecms/page'); types.delete('@apostrophecms/any-page-type'); } } function logMissing(id, log, reporting) { log.push({ _id: id, aposDocId: id.split(':')[0], type: null, title: null, relationship: false, error: 'apostrophe:notFound' }); if (reporting) { reporting.failure(); } } // Get documents for localization async function getDocs(req, manager, { ids }) { if (!ids.length) { return []; } const docs = await manager .findForEditing(req.clone({ mode: 'draft' }), { _id: { $in: ids } }) .toArray(); return docs; } // Check if the document can be localized, retrieve related documents // if necessary, and localize the documents to the specified locales. async function localizeDoc(req, reporting, { doc, relatedTypes, toLocales, update, relatedOnly, log = [], seen = new Set() }) { const docs = []; if (seen.has(doc.aposDocId)) { return log; } seen.add(doc.aposDocId); if (!canLocalize(req, doc, false)) { log.push({ _id: doc._id, aposDocId: doc.aposDocId, type: doc.type, title: doc.title, relationship: false, error: true }); self.logError( req, 'localize-batch-can-localize-error', 'The document type can\'t be localized or insufficient permissions', { id: doc._id, type: doc.type, title: doc.title, relationship: false } ); if (reporting) { reporting.failure(); } return log; } if (!relatedOnly) { docs.push(doc); } if (relatedTypes.size > 0) { try { await findRelatedDocs(req.clone({ mode: 'draft' }), { doc, schema: self.apos.modules[doc.type].schema, relatedTypes, memo: docs, seen }); } catch (e) { self.logError( req, 'localize-batch-related-error', 'Error finding related documents', { id: doc._id, type: doc.type, title: doc.title, error: e.message, stack: e.stack.split('\n').slice(1).map(line => line.trim()) } ); if (reporting) { reporting.failure(); } return log; } } let hasError = false; for (const item of docs) { const manager = self.apos.doc.getManager(item.type); // Not using Promise.allSettled because of potential // rate limit issues with external services when i.e. // automatically translating content. // Related info: // https://cookbook.openai.com/examples/how_to_handle_rate_limits for (const locale of toLocales) { const payload = { _id: item._id, aposDocId: item.aposDocId, locale, type: item.type, title: item.title, relationship: item.aposDocId !== doc.aposDocId ? doc.aposDocId : false, error: false }; try { await manager.localize(req, item, locale, { update: !payload.relationship ? true : update, batch: true }); log.push(payload); } catch (e) { hasError = true; payload.error = e.name ?? true; // This is the only detail that we know of. // XXX A better way to handle data sent to the UI as a // human-readable message is a standard error payload property. // For example `error.data.detail`. if (e.data?.parentNotLocalized) { payload.detail = req.t('apostrophe:parentNotLocalized'); } else { payload.detail = e.data?.detail ? req.t(e.data.detail) : null; } log.push(payload); // Do not flood the logs with errors that are expected const fn = e.name === 'conflict' ? 'logDebug' : 'logError'; const id = e.name === 'conflict' ? 'localize-batch-doc-conflict' : 'localize-batch-doc-error'; self[fn](req, id, { ...payload, error: e.message, reason: e.name, stack: e.stack.split('\n').slice(1).map(line => line.trim()) }); } } } // Advance the progress bar so that if the main document fails, // the batch progress will report the correct number of failures. // The detailed result should be printed in a custom notification. if (reporting) { const status = hasError ? 'failure' : 'success'; reporting[status](); } return log; } // Find related documents for localization async function findRelatedDocs(req, { doc, schema, relatedTypes, memo, seen }) { if (!schema) { return; } const partialDocs = getRelatedBySchema(req, doc, schema, seen) .filter(doc => relatedTypes.has(doc.type)) .filter(doc => canLocalize(req, doc, true)); const idsByType = partialDocs .reduce((acc, doc) => { acc[doc.type] ||= []; acc[doc.type].push(doc._id); return acc; }, {}); const promises = Object.entries(idsByType) .map(([ type, ids ]) => { return getDocs(req, self.apos.doc.getManager(type), { ids }); }); const results = await Promise.allSettled(promises); return results.filter(result => result.status === 'fulfilled') .map(result => { memo.push(...result.value); return result.value; }) .flat(); } // Get related documents by schema function getRelatedBySchema(req, object, schema, seen) { const related = []; for (const field of schema || []) { switch (field.type) { case 'array': { for (const value of (object[field.name] || [])) { related.push(...getRelatedBySchema(req, value, field.schema, seen)); } break; } case 'object': { if (object[field.name]) { related.push( ...getRelatedBySchema(req, object[field.name], field.schema, seen) ); } break; } case 'area': { for (const widget of (object[field.name]?.items || [])) { related.push( ...getRelatedBySchema( req, widget, self.apos.modules[`${widget?.type}-widget`]?.schema || [], seen ) ); } break; } case 'relationship': { for (const item of (object[field.name] || [])) { const id = item._id?.split(':')[0]; if (!id || seen.has(id)) { continue; } related.push(item); seen.add(id); } break; } default: // No-op break; } } return related; } // Filter out related doc types that opt out completely (pages should // never be considered "related" to other pages simply because // of navigation links, the feature is meant for pieces that feel more // like part of the document being localized) We also remove non // localized content like users and check for permissions.