From 7ff6ecb8e9ef379fdeb2e7b68ed9e24e429db809 Mon Sep 17 00:00:00 2001 From: Matthew White Date: Mon, 10 Aug 2020 22:47:46 -0400 Subject: [PATCH] Add destructure.js --- bin/transifex/destructure.js | 24 ++ bin/transifex/restructure.js | 235 +--------- bin/util/transifex.js | 574 +++++++++++++++++++++++++ bin/util/util.js | 38 ++ src/components/user/reset-password.vue | 4 +- src/components/user/retire.vue | 4 +- 6 files changed, 645 insertions(+), 234 deletions(-) create mode 100644 bin/transifex/destructure.js create mode 100644 bin/util/transifex.js create mode 100644 bin/util/util.js diff --git a/bin/transifex/destructure.js b/bin/transifex/destructure.js new file mode 100644 index 000000000..906a727be --- /dev/null +++ b/bin/transifex/destructure.js @@ -0,0 +1,24 @@ +const fs = require('fs'); + +const { destructure, readSourceMessages, writeTranslations } = require('../util/transifex'); +const { logThenThrow, mapComponentsToFiles } = require('../util/util'); + +const filenamesByComponent = mapComponentsToFiles('src/components'); +const sourceMessages = readSourceMessages('src/locales', filenamesByComponent); +for (const basename of fs.readdirSync('transifex')) { + // Skip .DS_Store and other dot files. + if (basename.startsWith('.')) continue; // eslint-disable-line no-continue + const match = basename.match(/^strings_([-\w]+)\.json$/); + if (match == null) logThenThrow(basename, 'invalid filename'); + const locale = match[1]; + + console.log(`destructuring ${locale}`); // eslint-disable-line no-console + writeTranslations( + locale, + sourceMessages, + destructure(fs.readFileSync(`transifex/${basename}`).toString()), + 'src/locales', + filenamesByComponent + ); +} +console.log('done'); // eslint-disable-line no-console diff --git a/bin/transifex/restructure.js b/bin/transifex/restructure.js index 3bcafd635..81d83e88a 100644 --- a/bin/transifex/restructure.js +++ b/bin/transifex/restructure.js @@ -1,235 +1,14 @@ -const R = require('ramda'); const fs = require('fs'); -// eslint-disable-next-line import/no-extraneous-dependencies -const { parse } = require('comment-json'); -const fallbackLocale = 'en'; - - - -//////////////////////////////////////////////////////////////////////////////// -// READ ROOT MESSAGES - -const rootJsonFilename = `src/locales/${fallbackLocale}.json`; -const messages = parse(fs.readFileSync(rootJsonFilename).toString()); - - - -//////////////////////////////////////////////////////////////////////////////// -// READ SINGLE FILE COMPONENTS - -// Get a list of single file components, sorted by component name. -const sfcs = []; -const dirs = ['src/components']; -while (dirs.length !== 0) { - const dir = dirs.pop(); - for (const basename of fs.readdirSync(dir)) { - const path = `${dir}/${basename}`; - if (fs.statSync(path).isDirectory()) { - dirs.push(path); - } else if (path.endsWith('.vue')) { - const componentName = path - .replace('src/components', '') - .replace(/\.vue$/, '') - .replace(/[-/](.)/g, (_, c) => c.toUpperCase()); - sfcs.push({ componentName, filename: path }); - } - } -} -sfcs.sort(R.comparator((sfc1, sfc2) => - sfc1.componentName < sfc2.componentName)); - -messages.component = {}; -for (const { componentName, filename } of sfcs) { - const content = fs.readFileSync(filename).toString(); - const match = content.match(//); - if (match != null) { - const begin = match.index + match[0].length; - const end = content.indexOf('', begin); - if (end === -1) throw new Error('invalid single file component'); - // Trimming so that if there is an error, the line number is clear. - const json = content.slice(begin, end).trim(); - try { - messages.component[componentName] = parse(json)[fallbackLocale]; - } catch (e) { - // eslint-disable-next-line no-console - console.error(`could not parse the i18n JSON of ${componentName}`); - throw e; - } - } -} - - - -//////////////////////////////////////////////////////////////////////////////// -// PLURAL FORMS - -const validatePluralForms = (forms) => { - if (forms.length > 2) - throw new Error('pluralized messages must have exactly two forms'); - const vars = forms.map(form => { - const match = form.match(/{\w+}/g); - return match != null ? match.sort() : []; - }); - for (let i = 0; i < forms.length; i += 1) { - // Braces used outside of variables could be an issue for ICU plurals. - const match = forms[i].match(/[{}]/g); - if (match != null && match.length !== 2 * vars[i].length) - throw new Error('unexpected brace'); - - // Single quotes are used for escaping in ICU plurals. - if (forms[i].includes("'")) - throw new Error("We don't support straight single quotes in ICU plurals, but curly quotes are supported."); - - if (forms[i].includes('#')) throw new Error('unexpected #'); - } - if (forms.length === 2) { - if (vars[0].length !== vars[1].length) - throw new Error('pluralized messages must use the same number of variables in each form'); - for (let i = 0; i < vars[0].length; i += 1) { - if (vars[0][i] !== vars[1][i]) - throw new Error('pluralized messages must use the same variables in each form'); - } - } -}; -const splitPluralForms = (message) => { - const split = message.split(' | '); - validatePluralForms(split); - return split; -}; -// Returns a string for Transifex that may use ICU plurals. -const joinPluralForms = (forms) => { - validatePluralForms(forms); - return forms.length === 2 - ? `{count, plural, one {${forms[0]}} other {${forms[1]}}}` - // It seems that there is no issue if the string starts with a variable - // (that is, with an open brace). - : forms[0]; -}; - - - -//////////////////////////////////////////////////////////////////////////////// -// DEVELOPER COMMENTS - -const commentsByKey = {}; -for (const { value } of messages[Symbol.for('before-all')]) { - const match = value.trim().match(/^(\w+):[ \t]*(.+)$/); - // eslint-disable-next-line prefer-destructuring - if (match != null) commentsByKey[match[1]] = match[2]; -} - -// Returns a comment for a message whose path ends with .full or for a sibling -// message. -const getCommentForFull = (obj, key, entries) => { - if (key === 'full') { - const siblings = entries.filter(([k, v]) => { - if (k === 'full') return false; - if (typeof v !== 'string') throw new Error('invalid sibling'); - return true; - }); - if (siblings.length === 0) return null; - - if (siblings.length === 1) { - const [k, v] = siblings[0]; - const forms = splitPluralForms(v); - return forms.length === 1 - ? `{${k}} is a separate string that will be translated below. Its text will be formatted within ODK Central, for example, it might be bold or a link. Its text is:\n\n${v}` - // Showing the plural form instead of the singular, because that is what - // Transifex initially shows for an English string with a plural form. - : `{${k}} is a separate string that will be translated below. Its text will be formatted within ODK Central, for example, it might be bold or a link. In its plural form, its text is:\n\n${forms[1]}`; - } - - const joined = siblings - .map(([k, v]) => { - const forms = splitPluralForms(v); - return forms.length === 1 - ? `- {${k}} has the text: ${v}` - : `- {${k}} has the plural form: ${forms[1]}`; - }) - .join('\n'); - return `The following are separate strings that will be translated below. They will be formatted within ODK Central, for example, they might be bold or a link.\n\n${joined}`; - } - - if (obj.full != null) { - // obj.full will be an array if $tcPath() is used. - if (Array.isArray(obj.full)) - return `This text will be formatted within ODK Central, for example, it might be bold or a link. It will be inserted where {${key}} is in the following text. (The plural form of the text is shown.)\n\n${obj.full[1]}`; - if (typeof obj.full !== 'string') throw new Error('invalid .full message'); - return `This text will be formatted within ODK Central, for example, it might be bold or a link. It will be inserted where {${key}} is in the following text:\n\n${obj.full}`; - } - - return null; -}; - - - -//////////////////////////////////////////////////////////////////////////////// -// CONVERT TO STRUCTURED JSON - -const restructure = ( - value, - commentForPath = null, - commentForKey = null, - commentForFull = null -) => { - if (value == null) throw new Error('invalid value'); - - if (typeof value === 'string') { - const structured = { string: joinPluralForms(splitPluralForms(value)) }; - - if (commentForPath != null) { - structured.developer_comment = commentForFull != null - ? `${commentForPath}\n\n${commentForFull}` - : commentForPath; - } else if (commentForKey != null) { - structured.developer_comment = commentForFull != null - ? `${commentForKey}\n\n${commentForFull}` - : commentForKey; - } else if (commentForFull != null) { - structured.developer_comment = commentForFull; - } - - return structured; - } - - if (typeof value !== 'object') throw new Error('invalid value'); - - // `structured` will be a non-array object, even if `value` is an array: it - // seems that structured JSON does not support arrays. - const structured = {}; - const entries = Object.entries(value); - for (const [k, v] of entries) { - // Skip linked locale messages. - if (typeof v === 'string' && /^@:\w+(\.\w+)*$/.test(v)) - continue; // eslint-disable-line no-continue - - const comments = value[Symbol.for(`before:${k}`)]; - structured[k] = restructure( - // v will be an array if $tcPath() is used. - k === 'full' && Array.isArray(v) ? v.join(' | ') : v, - comments != null - ? comments.map(comment => comment.value.trim()).join(' ') - : commentForPath, - commentsByKey[k] != null ? commentsByKey[k] : commentForKey, - getCommentForFull(value, k, entries) - ); - - // Remove an object that only contains linked locale messages. - if (typeof v === 'object' && Object.keys(structured[k]).length === 0) - delete structured[k]; - } - return structured; -}; +const { readSourceMessages, restructure, sourceLocale } = require('../util/transifex'); +const { mapComponentsToFiles } = require('../util/util'); +const messages = readSourceMessages( + 'src/locales', + mapComponentsToFiles('src/components') +); const structured = restructure(messages); - - - -//////////////////////////////////////////////////////////////////////////////// -// WRITE OUTPUT - fs.writeFileSync( - `transifex/strings_${fallbackLocale}.json`, + `transifex/strings_${sourceLocale}.json`, JSON.stringify(structured, null, 2) ); diff --git a/bin/util/transifex.js b/bin/util/transifex.js new file mode 100644 index 000000000..058d79933 --- /dev/null +++ b/bin/util/transifex.js @@ -0,0 +1,574 @@ +const fs = require('fs'); +const { equals } = require('ramda'); +// eslint-disable-next-line import/no-extraneous-dependencies +const { parse } = require('comment-json'); + +const { logThenThrow } = require('./util'); + +const sourceLocale = 'en'; + + + +//////////////////////////////////////////////////////////////////////////////// +// VARIABLES + +// Returns an array of the variables and component interpolation slots used in a +// message. The array will contain the name of the variable or slot for each +// time it is used in the message. For a pluralized message, call parseVars() +// for each plural form. +const parseVars = (pluralForm) => { + const varMatches = pluralForm.match(/{\w+}/g); + const vars = varMatches != null ? varMatches.sort() : []; + // Braces used outside of variables could be an issue. + const braceMatches = pluralForm.match(/[{}]/g); + if (braceMatches != null && braceMatches.length !== 2 * vars.length) + logThenThrow(pluralForm, 'unexpected brace'); + return vars; +}; + + + +//////////////////////////////////////////////////////////////////////////////// +// PLURALS + +// PluralForms is an array-like object with an element for each plural form of a +// message. It provides methods to convert to or from an Vue I18n message or a +// Transifex string. If a message is not pluralized, PluralForms will contain a +// single element. +class PluralForms { + static empty(length) { return new PluralForms(new Array(length).fill('')); } + + static fromVueI18n(message) { + const forms = message.split(' | '); + if (forms.length > 2) + logThenThrow(message, 'a pluralized message must have exactly two forms'); + + for (const form of forms) { + if (form.includes('|')) logThenThrow(message, 'unexpected |'); + if (/(^\s|\s$|\s\s)/.test(form)) + logThenThrow(message, 'unexpected white space'); + } + + return new PluralForms(forms); + } + + // Transifex uses ICU plurals. + static fromTransifex(string) { + const icuMatch = string.match(/^({count, plural,).+}$/); + const forms = []; + if (icuMatch == null) { + forms.push(string); + } else { + for (let begin = icuMatch[1].length; begin < string.length - 1;) { + // Using a single RegExp along with lastIndex might be more efficient. + const formMatch = string.slice(begin).match(/^ [a-z]+ {/); + if (formMatch == null) logThenThrow(string, 'invalid plural'); + let end = begin + formMatch[0].length; + let unmatchedBraces = 1; + for (; unmatchedBraces > 0 && end < string.length - 1; end += 1) { + if (string[end] === '{') + unmatchedBraces += 1; + else if (string[end] === '}') + unmatchedBraces -= 1; + } + if (unmatchedBraces !== 0) logThenThrow(string, 'unmatched brace'); + forms.push(string.slice(begin + formMatch[0].length, end - 1)); + begin = end; + } + } + + for (let i = 0; i < forms.length; i += 1) + forms[i] = forms[i].trim().replace(/\s+/g, ' '); + + return new PluralForms(forms); + } + + constructor(forms) { + if (forms.length === 0) throw new Error('forms cannot be empty'); + this[0] = forms[0]; // eslint-disable-line prefer-destructuring + const vars = parseVars(forms[0]); + for (let i = 1; i < forms.length; i += 1) { + if (!equals(parseVars(forms[i]), vars)) + logThenThrow(forms, 'plural forms must use the same variables in each form'); + this[i] = forms[i]; + } + this.length = forms.length; + } + + isEmpty() { + for (let i = 0; i < this.length; i += 1) { + if (this[i] !== '') return false; + } + return true; + } + + toVueI18n() { + for (let i = 0; i < this.length; i += 1) { + if (this[i].includes('|')) logThenThrow(this, 'unexpected |'); + } + return Array.from(this).join(' | '); + } + + toTransifex() { + for (let i = 0; i < this.length; i += 1) { + // Single quotes are used for escaping in ICU plurals. + if (this[i].includes("'")) + logThenThrow(this, "We don't support straight single quotes in ICU plurals, but curly quotes are supported."); + // Used in ICU plurals + if (this[i].includes('#')) logThenThrow(this, 'unexpected #'); + } + if (this.length > 2) logThenThrow(this, 'too many plural forms'); + return this.length === 2 + ? `{count, plural, one {${this[0]}} other {${this[1]}}}` + // It seems that there is no issue if the string starts with a variable + // (that is, with an open brace). + : this[0]; + } +} + + + +//////////////////////////////////////////////////////////////////////////////// +// LINKED LOCALE MESSAGES + +const pathOfLinkedMessage = (pluralForms) => { + if (pluralForms.length !== 1) return null; + const match = pluralForms[0].match(/^@:([\w.]+)$/); + return match != null ? match[1].split('.') : null; +}; + + + +//////////////////////////////////////////////////////////////////////////////// +// JSON CONVERSION + +// Generates comments related to component interpolation. +const generateCommentForFull = (obj, key) => { + const { full } = obj; + if (full == null) return null; + if (!(full instanceof PluralForms)) + logThenThrow(full, 'invalid full property'); + + if (key !== 'full') { + return full.length === 1 + ? `This text will be formatted within ODK Central, for example, it might be bold or a link. It will be inserted where {${key}} is in the following text:\n\n${full[0]}` + // Showing the plural form instead of the singular, because that is what + // Transifex initially shows for an English string with a plural form. + : `This text will be formatted within ODK Central, for example, it might be bold or a link. It will be inserted where {${key}} is in the following text. (The plural form of the text is shown.)\n\n${full[1]}`; + } + + const siblings = Object.entries(obj).filter(([k, v]) => { + if (k === 'full') return false; + if (!(v instanceof PluralForms)) logThenThrow(obj, 'invalid sibling'); + return true; + }); + if (siblings.length === 0) logThenThrow(obj, 'sibling not found'); + + if (siblings.length === 1) { + const [k, forms] = siblings[0]; + return forms.length === 1 + ? `{${k}} is a separate string that will be translated below. Its text will be formatted within ODK Central, for example, it might be bold or a link. Its text is:\n\n${forms[0]}` + : `{${k}} is a separate string that will be translated below. Its text will be formatted within ODK Central, for example, it might be bold or a link. In its plural form, its text is:\n\n${forms[1]}`; + } + + const joined = siblings + .map(([k, forms]) => (forms.length === 1 + ? `- {${k}} has the text: ${forms[0]}` + : `- {${k}} has the plural form: ${forms[1]}`)) + .join('\n'); + return `The following are separate strings that will be translated below. They will be formatted within ODK Central, for example, they might be bold or a link.\n\n${joined}`; +}; + +// Converts Vue I18n JSON to structured JSON, returning an object. +const _restructure = ( + value, + commentsByKey, + commentForPath, + commentForKey, + commentForFull +) => { + if (value == null) throw new Error('invalid value'); + + if (value instanceof PluralForms) { + const structured = { string: value.toTransifex() }; + + if (commentForPath != null) { + structured.developer_comment = commentForFull != null + ? `${commentForPath}\n\n${commentForFull}` + : commentForPath; + } else if (commentForKey != null) { + structured.developer_comment = commentForFull != null + ? `${commentForKey}\n\n${commentForFull}` + : commentForKey; + } else if (commentForFull != null) { + structured.developer_comment = commentForFull; + } + + return structured; + } + + if (typeof value !== 'object') throw new Error('invalid value'); + + // `structured` will be a non-array object, even if `value` is an array: it + // seems that structured JSON does not support arrays. + const structured = {}; + const entries = Object.entries(value); + for (const [k, v] of entries) { + // Skip linked locale messages. + if (v instanceof PluralForms && pathOfLinkedMessage(v) != null) + continue; // eslint-disable-line no-continue + + const comments = value[Symbol.for(`before:${k}`)]; + structured[k] = _restructure( + v, + commentsByKey, + comments != null + ? comments.map(comment => comment.value.trim()).join(' ') + : commentForPath, + commentsByKey[k] != null ? commentsByKey[k] : commentForKey, + generateCommentForFull(value, k) + ); + + // Remove an object that only contains linked locale messages. + if (typeof v === 'object' && Object.keys(structured[k]).length === 0) + delete structured[k]; + } + return structured; +}; +const restructure = (messages) => { + const commentsByKey = {}; + for (const { value } of messages[Symbol.for('before-all')]) { + const match = value.trim().match(/^(\w+):[ \t]*(.+)$/); + // eslint-disable-next-line prefer-destructuring + if (match != null) commentsByKey[match[1]] = match[2]; + } + + return _restructure(messages, commentsByKey, null, null, null); +}; + +// Converts structured JSON to Vue I18n JSON, returning an object where each +// message is a PluralForms object. +const destructure = (json) => JSON.parse( + json, + (_, value) => { + if (value != null && typeof value === 'object' && + typeof value.string === 'string') + return PluralForms.fromTransifex(value.string); + return value; + } +); + + + +//////////////////////////////////////////////////////////////////////////////// +// READ SOURCE MESSAGES + +// Returns the Vue I18n messages for the source locale after converting them to +// PluralForms objects. +const readSourceMessages = (localesDir, filenamesByComponent) => { + // Read the root messages. + const reviver = (key, value) => { + if (typeof value === 'string') return PluralForms.fromVueI18n(value); + if (key === 'full') { + // `value` will be an array if $tcPath() is used. + if (!Array.isArray(value)) logThenThrow(value, 'invalid full property'); + return new PluralForms(value.map(pluralForms => { + if (pluralForms.length !== 1) + logThenThrow(value, 'invalid full property'); + return pluralForms[0]; + })); + } + return value; + }; + const messages = parse( + fs.readFileSync(`${localesDir}/${sourceLocale}.json`).toString(), + reviver + ); + + // Read the component messages. + messages.component = {}; + for (const [componentName, filename] of filenamesByComponent) { + const content = fs.readFileSync(filename).toString(); + const match = content.match(//); + if (match != null) { + const begin = match.index + match[0].length; + const end = content.indexOf('', begin); + if (end === -1) logThenThrow(filename, 'invalid single file component'); + // Trimming so that if there is an error, the line number is clear. + const json = content.slice(begin, end).trim(); + try { + messages.component[componentName] = parse(json, reviver)[sourceLocale]; + } catch (e) { + // eslint-disable-next-line no-console + console.error(`could not parse the Vue I18n JSON of ${componentName}`); + throw e; + } + } + } + + return messages; +}; + + + +//////////////////////////////////////////////////////////////////////////////// +// WRITE TRANSLATIONS + +// Stores a source message with the corresponding translation. +class Translation { + constructor(parent, key) { + this.parent = parent; + this._key = key; + this.source = parent._source[key]; + } + + get root() { return this.parent.root; } + + // The translation is "live": if it changes in the parent, it will change here + // as well. + get translated() { return this.parent._translated[this._key]; } + + toJSON(key) { + if (this.translated.isEmpty()) return undefined; + return key === 'full' && this.translated.length !== 1 + ? Array.from(this.translated) + : this.translated.toVueI18n(); + } +} + +// `Translations` stores an object of source messages with the corresponding +// translations. It provides methods to modify the translations. +class Translations { + constructor(parent, key, source, translated) { + this.parent = parent; + this._key = key; + if (source == null || typeof source !== 'object') + logThenThrow(source, 'invalid source'); + this._source = source; + if (translated == null || typeof translated !== 'object') + logThenThrow(translated, 'invalid translated'); + this._translated = translated; + this.size = Object.keys(source).length; + } + + get root() { return this.parent == null ? this : this.parent.root; } + + // Returns a translation or another set of translations. In either case, the + // result is "live": a change to this node will be reflected in its children. + get(key) { + const sourceValue = this._source[key]; + if (sourceValue == null) return undefined; + if (sourceValue instanceof PluralForms) return new Translation(this, key); + if (this._translated[key] == null) this._translated[key] = {}; + return new Translations(this, key, sourceValue, this._translated[key]); + } + + has(key) { return this._source[key] != null; } + + // Sets a single translation. + set(key, translated) { + if (!(this._source[key] instanceof PluralForms)) + logThenThrow(key, 'invalid key'); + if (!(translated instanceof PluralForms)) + logThenThrow(translated, 'invalid translated'); + this._translated[key] = translated; + return this; + } + + // Removes a translation or set of translations. + delete(key) { + const value = this._translated[key]; + if (value == null) return; + if (value instanceof PluralForms) + this._translated[key] = PluralForms.empty(value.length); + else + this.get(key).clear(); + } + + clear() { + for (const key of Object.keys(this._translated)) + this.delete(key); + } + + // Visit each translation. + walk(callbacks) { + if (typeof callbacks === 'function') { + this.walk([callbacks]); + return; + } + + for (const key of Object.keys(this._source)) { + const value = this.get(key); + if (value instanceof Translation) { + for (const callback of callbacks) + callback(key, value); + } else { + value.walk(callbacks); + } + } + } + + toJSON(key) { + const keyIsIndex = /^\d+$/.test(key); + + if (this.has('0')) { + const result = []; + let emptyPluralForms = 0; + let emptyObjects = 0; + for (let i = 0; i < this.size; i += 1) { + const k = i.toString(); + if (!this.has(k)) + logThenThrow(this, 'error converting object to array'); + const value = this.get(k).toJSON(k); + result.push(value); + if (value === undefined) + emptyPluralForms += 1; + else if (Object.keys(value).length === 0) + emptyObjects += 1; + } + if (emptyPluralForms + emptyObjects === this.size) { + // `this` has only empty translations. If possible, we return + // `undefined` so that there is not an empty array in the JSON. However, + // we do not return `undefined` if doing so would result in a sparse + // array: JSON does not support sparse arrays. + return keyIsIndex ? [] : undefined; + } + if (emptyPluralForms !== 0) logThenThrow(this, 'sparse array'); + return result; + } + + const result = {}; + for (const k of Object.keys(this._translated)) { + const value = this.get(k).toJSON(k); + if (value != null) result[k] = value; + } + return Object.keys(result).length !== 0 || keyIsIndex ? result : undefined; + } +} + +// This will not work for a linked locale message in an i18n custom block, but +// we do not use those yet. +const copyLinkedLocaleMessage = (key, { source, parent }) => { + const path = pathOfLinkedMessage(source); + if (path == null) return; + let linked = parent.root; + for (let i = 0; linked != null && i < path.length; i += 1) + linked = linked.get(path[i]); + if (linked != null && linked.translated != null) parent.set(key, source); +}; + +const verifyDestructure = (_, { source, translated }) => { + if (translated == null || !equals(Array.from(source), Array.from(translated))) + logThenThrow({ source, translated }, 'mismatch for source locale'); +}; + +// If a component interpolation is only partially translated, we remove the +// partial translation so that the resulting text is not a mix of locales. We +// also remove an array that is missing a translation, because JSON does not +// support sparse arrays. +const deletePartialTranslation = (key, { source, translated, parent }) => { + if (parent.has('full') || parent.has('0')) { + // Linked locale message + if (translated == null) logThenThrow(source, 'not supported'); + if (translated.isEmpty()) parent.clear(); + } +}; + +const validateTranslation = (_, { source, translated }) => { + if ((source.length !== 1) !== (translated.length !== 1)) + logThenThrow({ source, translated }, 'pluralization mismatch'); + if (!translated.isEmpty() && + !equals(parseVars(source[0]), parseVars(translated[0]))) + logThenThrow({ source, translated }, 'translation must use the same variables as the source message'); +}; + +// Writes the translations for the specified locale. +const writeTranslations = ( + locale, + source, + translated, + localesDir, + filenamesByComponent +) => { + const translations = new Translations(null, null, source, translated); + + if (locale === sourceLocale) { + translations.walk([copyLinkedLocaleMessage, verifyDestructure]); + return; + } + + translations.walk(deletePartialTranslation); + // Walking twice so that we copy a linked locale message only if + // deletePartialTranslation won't delete it. + translations.walk([copyLinkedLocaleMessage, validateTranslation]); + + const translationsByComponent = translations.get('component'); + const autogenerated = { + open: '\n\n', + close: '\n\n' + }; + // Needed to prevent an ESLint vue/no-parsing-error. + const escapeJSON = (json) => json.replace(/ { + console.error(toLog); // eslint-disable-line no-console + throw new Error(errorMessage); +}; + +const mapComponentsToFiles = (componentsDir) => { + const sfcs = []; + const dirs = [componentsDir]; + while (dirs.length !== 0) { + const dir = dirs.pop(); + for (const basename of fs.readdirSync(dir)) { + const path = `${dir}/${basename}`; + if (fs.statSync(path).isDirectory()) { + dirs.push(path); + } else if (path.endsWith('.vue')) { + const componentName = path + .replace('src/components', '') + .replace(/\.vue$/, '') + .replace(/[-/](.)/g, (_, c) => c.toUpperCase()); + sfcs.push({ componentName, filename: path }); + } + } + } + return sfcs + .sort(comparator((sfc1, sfc2) => sfc1.componentName < sfc2.componentName)) + .reduce( + (map, { componentName, filename }) => map.set(componentName, filename), + new Map() + ); +}; + +module.exports = { + logThenThrow, + mapComponentsToFiles +}; diff --git a/src/components/user/reset-password.vue b/src/components/user/reset-password.vue index d54b0c3fe..84ff65dfd 100644 --- a/src/components/user/reset-password.vue +++ b/src/components/user/reset-password.vue @@ -71,17 +71,15 @@ export default { }; - { "en": { // This is the title at the top of a pop-up. "title": "Reset Password", "introduction": { - "full": "Once you click {resetPassword} below, the password for the user “{displayName}” <{email}> will be immediately invalidated. An email will be sent to {email} with instructions on how to proceed.", + "full": "Once you click {resetPassword} below, the password for the user “{displayName}” \u003c{email}> will be immediately invalidated. An email will be sent to {email} with instructions on how to proceed.", "resetPassword": "Reset password" } } } - diff --git a/src/components/user/retire.vue b/src/components/user/retire.vue index d0a59df51..a169b0793 100644 --- a/src/components/user/retire.vue +++ b/src/components/user/retire.vue @@ -72,7 +72,6 @@ export default { }; - { "en": { @@ -80,7 +79,7 @@ export default { // pop-up to retire another Web User. "title": "Retiring User", "introduction": [ - "You are about to retire the user account “{displayName}” <{email}>. That user will be immediately barred from performing any actions and logged out.", + "You are about to retire the user account “{displayName}” \u003c{email}>. That user will be immediately barred from performing any actions and logged out.", { "full": "{noUndo}, but a new account can always be created for that person with the same email address.", "noUndo": "This action cannot be undone" @@ -89,4 +88,3 @@ export default { } } -