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 {
}
}
-