diff --git a/lib/add-fluent-pattern.js b/lib/add-fluent-pattern.js index 99da822..c734815 100644 --- a/lib/add-fluent-pattern.js +++ b/lib/add-fluent-pattern.js @@ -5,14 +5,17 @@ import { Message, Pattern, Placeable, + SelectExpression, TextElement, - VariableReference + VariableReference, + Variant } from '@fluent/syntax' /** * @typedef {{ type: 'COPY', source: string }} CopyTransform - * @typedef {{ type: 'REPLACE', source: string, target: string, map: Array<{ from: string, to: string }> }} ReplaceTransform - * @typedef { CopyTransform | ReplaceTransform } PatternTransform + * @typedef {{ type: 'REPLACE', source: string, map: Array<{ from: string, to: string }> }} ReplaceTransform + * @typedef {{ type: 'PLURALS', source: string, selector: string, map: Array<{ from: string, to: string }> }} PluralsTransform + * @typedef { CopyTransform | ReplaceTransform | PluralsTransform } PatternTransform * * @typedef {{ * id: string, @@ -34,14 +37,9 @@ import { * @param {Comment | null} comment * @param {import('./migrate-message').MessageMigration} migration */ -export function addFluentPattern( - ftl, - transforms, - node, - comment, - { key, attr, varNames } -) { - /** @type {Message} */ +export function addFluentPattern(ftl, transforms, node, comment, migration) { + const { key, attr } = migration + /** @type {Message | undefined} */ let msg = ftl.body.find( (entry) => entry.type === 'Message' && entry.id.name === key ) @@ -59,16 +57,22 @@ export function addFluentPattern( let cc = comment?.content || '' let vc = 'Variables:' let hasVar = false - const pattern = parseMsgPattern(node, varNames, (prev, next) => { - hasVar = true - let desc = 'FIXME' - const re = new RegExp(`${prev.replace('$', '\\$')}([^%\n]+)`) - cc = cc.replace(re, (_, prevDesc) => { - desc = prevDesc.replace(/^\s*(is\s*)?/, '').replace(/\s*[;,.]?\s*$/, '.') - return '' - }) - vc += `\n $${next} (String): ${desc}` - }) + const pattern = parseMessage( + node, + migration, + (/** @type {string} */ prev, /** @type {string} */ next) => { + hasVar = true + let desc = 'FIXME' + const re = new RegExp(`${prev.replace('$', '\\$')}([^%\n]+)`) + cc = cc.replace(re, (_, prevDesc) => { + desc = prevDesc + .replace(/^\s*(is\s*)?/, '') + .replace(/\s*[;,.]?\s*$/, '.') + return '' + }) + vc += `\n $${next} (String): ${desc}` + } + ) if (hasVar) { const fc = (cc.replace(/\s+\n|\s*$/g, '\n') + vc).trim() if (comment) comment.content = fc @@ -92,18 +96,69 @@ export function addFluentPattern( /** * @param {import('dot-properties').Pair} node + * @param {import('./migrate-message').MessageMigration} migration + * @param {Function} fixArgNameInComment + * @returns {{ ftl: Pattern, transform: PatternTransform }} + */ +function parseMessage( + { key, value }, + { plural, varNames }, + fixArgNameInComment +) { + if (plural) { + const sep = value.indexOf(';') + const caseOne = value.substring(0, sep) + const caseOther = value.substring(sep + 1) + + /** @type {Variant[]} */ + const variants = [] + /** @type {ReplaceTransform['map'] | null} */ + let map = null + + if (caseOne) { + const one = parseMsgPattern(caseOne, varNames, fixArgNameInComment) + variants.push(new Variant(new Identifier('one'), one.ftl, false)) + map = one.map + } + + const other = parseMsgPattern(caseOther, varNames, fixArgNameInComment) + variants.push(new Variant(new Identifier('other'), other.ftl, true)) + map = map ? map.concat(other.map) : other.map + + const selector = new VariableReference(new Identifier(plural)) + const selExp = new SelectExpression(selector, variants) + return { + ftl: new Pattern([new Placeable(selExp)]), + transform: { type: 'PLURALS', source: key, selector: plural, map } + } + } else { + const { ftl, map } = parseMsgPattern(value, varNames, fixArgNameInComment) + + /** @type {PatternTransform} */ + const transform = + Object.keys(map).length === 0 + ? { type: 'COPY', source: key } + : { type: 'REPLACE', source: key, map } + + return { ftl, transform } + } +} + +/** + * @param {string} source * @param {string[]} varNames * @param {Function} fixArgNameInComment */ -function parseMsgPattern({ key, value }, varNames, fixArgNameInComment) { +function parseMsgPattern(source, varNames, fixArgNameInComment) { + /** @type {Array} */ const elements = [] let done = 0 let num = 0 /** @type {ReplaceTransform['map']} */ const map = [] - for (const match of value.matchAll(/%(\d\$)?S/g)) { + for (const match of source.matchAll(/%(\d\$)?S/g)) { if (match.index > done) - elements.push(new TextElement(value.substring(done, match.index))) + elements.push(new TextElement(source.substring(done, match.index))) num = match[1] ? parseInt(match[1]) : num + 1 const name = varNames?.[num - 1] || `var${num}` const id = new Identifier(name) @@ -112,13 +167,7 @@ function parseMsgPattern({ key, value }, varNames, fixArgNameInComment) { done = match.index + match[0].length fixArgNameInComment(match[0], name) } - if (done < value.length) elements.push(new TextElement(value.substring(done))) - - /** @type {PatternTransform} */ - const transform = - Object.keys(map).length === 0 - ? { type: 'COPY', source: key } - : { type: 'REPLACE', source: key, map } - - return { ftl: new Pattern(elements), transform } + if (done < source.length) + elements.push(new TextElement(source.substring(done))) + return { ftl: new Pattern(elements), map } } diff --git a/lib/migrate-message.js b/lib/migrate-message.js index fe806a7..c50166a 100644 --- a/lib/migrate-message.js +++ b/lib/migrate-message.js @@ -1,7 +1,12 @@ import kebabCase from 'lodash.kebabcase' /** - * @typedef {{ key: string, attr: string | null, varNames: string[] }} MessageMigration + * @typedef {{ + * key: string, + * attr: string | null, + * plural: string | false | null, + * varNames: string[] + * }} MessageMigration */ /** @@ -15,8 +20,12 @@ import kebabCase from 'lodash.kebabcase' * @returns {MessageMigration} */ export function migrateMessage(propData, propKey, varNames) { - if (!varNames) varNames = initVarNames(propData, propKey) - const { migrate } = propData + const { ast, migrate } = propData + /** @type {import('dot-properties').Pair | undefined} */ + const propNode = ast.find( + (node) => node.type === 'PAIR' && node.key === propKey + ) + if (!varNames) varNames = initVarNames(propNode) const prev = migrate[propKey] if (prev) { for (let i = prev.varNames.length; i < varNames.length; ++i) { @@ -49,16 +58,15 @@ export function migrateMessage(propData, propKey, varNames) { return { key: getFtlKey(propData, key), attr: attr ? kebabCase(attr) : null, + plural: propNode?.value?.includes(';') ? 'FIXME' : null, varNames } } /** - * @param {import('./parse-message-files.js').PropData} propData - * @param {string} propKey + * @param {import('dot-properties').Pair | undefined} node */ -function initVarNames({ ast }, propKey) { - const node = ast.find((node) => node.type === 'PAIR' && node.key === propKey) +function initVarNames(node) { /** @type {string[]} */ const varNames = [] if (node) { @@ -78,7 +86,7 @@ function initVarNames({ ast }, propKey) { /** * @param {import('./parse-message-files.js').PropData} propData - * @param {string} propKey + * @param {string} key * @returns {string} */ function getFtlKey({ migrate, ftlPrefix, ftl }, key) { diff --git a/lib/migration-config.js b/lib/migration-config.js index f270a20..c118ed4 100644 --- a/lib/migration-config.js +++ b/lib/migration-config.js @@ -12,9 +12,11 @@ export const configPath = (propPath) => propPath.replace(/properties$/, 'migration.yaml') /** + * @param {string} root * @param {string} propPath - Path to `.properties` file * @returns {Promise<{ * ftl: { root: string, path: string }, + * meta: { bug: string, title: string }, * migrate: Record * } | null>} */ @@ -30,6 +32,13 @@ export async function readMigrationConfig(root, propPath) { const relPath = relative(root, cfgPath) console.warn(`Found migration config at ${relPath}`) + /** + * @type {{ + * ftl?: { root: string, path: string }, + * meta?: { bug: string, title: string }, + * migrate: Record + * }} + */ const { ftl, meta, migrate } = parse(src, { schema: 'failsafe' }) if ( !meta || @@ -73,9 +82,20 @@ export async function readMigrationConfig(root, propPath) { !Array.isArray(value.varNames) ) { throw new Error( - `Invalid migrate.${key} value in ${relPath}, expected { key: string, attr?: string, varNames?: string[] } or null` + `Invalid migrate.${key} value in ${relPath}, expected { key: string, attr?: string, plural?: string | false, varNames?: string[] } or null` ) } + if ('plural' in value) { + if ( + value.plural + ? typeof value.plural !== 'string' + : value.plural !== false + ) { + throw new Error( + `Invalid migrate.${key}.plural value in ${relPath}, expected non-empty string or false` + ) + } + } else value.plural = null } } @@ -85,7 +105,7 @@ export async function readMigrationConfig(root, propPath) { /** * @param {import('./parse-message-files.js').PropData} propData * @param {import('./transform-js.js').TransformOptions} options - * @returns {string} Path to migration config file + * @returns {Promise} Path to migration config file */ export async function writeMigrationConfig(propData, { all, bug, root }) { const { ast, ftlPath, ftlRoot, migrate, msgKeys, path } = propData @@ -116,9 +136,12 @@ export async function writeMigrationConfig(propData, { all, bug, root }) { if (!bug) doc.getIn(['meta', 'bug'], true).comment = ' FIXME' for (const pair of doc.contents.items) pair.key.spaceBefore = true + /** @type {string[]} */ const fixVars = [] doc.get('migrate').items = msgKeys.map((propKey) => { + /** @type {import('yaml').Pair} */ let pair + /** @type {import('./migrate-message.js').MessageMigration | null} */ let mm = null if (migrate.hasOwnProperty(propKey)) { mm = migrate[propKey] @@ -131,6 +154,10 @@ export async function writeMigrationConfig(propData, { all, bug, root }) { const mc = prettyMessageMigration(migrateMessage(propData, propKey, null)) pair.value.commentBefore = stringify(mc).replace(/^/gm, ' ').trimEnd() } + if (mm?.plural) { + const node = pair.value.get('plural', true) + if (node && !node.value) node.comment = ' FIXME' + } if (mm?.varNames) { for (let i = 0; i < mm.varNames.length; ++i) { if (/^var\d+$/.test(mm.varNames[i])) { @@ -176,9 +203,11 @@ it should NOT be added to the source repo. /** * @param {import('./migrate-message').MessageMigration} migration */ -function prettyMessageMigration({ key, attr, varNames }) { +function prettyMessageMigration({ key, attr, plural, varNames }) { + /** @type {Partial} */ const value = { key } if (attr) value.attr = attr + if (plural) value.plural = null if (varNames.length > 0) value.varNames = varNames return value } diff --git a/lib/stringify-transform.js b/lib/stringify-transform.js index c3257fd..d9a1809 100644 --- a/lib/stringify-transform.js +++ b/lib/stringify-transform.js @@ -1,6 +1,6 @@ import { relative, resolve } from 'path' -const l10nPath = (root, path) => +const l10nPath = (/** @type {string} */ root, /** @type {string} */ path) => (path[0] === '/' ? relative(root, path) : path).replace('/locales/en-US', '') /** @@ -57,7 +57,7 @@ def migrate(ctx): /** * @param {import('./add-fluent-pattern.js').PatternTransform} pt - * @param {{ helpers: Set, transforms: Set} imports + * @param {{ helpers: Set, transforms: Set}} imports */ function compilePattern(pt, imports) { const key = JSON.stringify(pt.source) @@ -74,6 +74,22 @@ function compilePattern(pt, imports) { ) return `REPLACE(source, ${key}, { ${replace.join(', ')} })` } + case 'PLURALS': { + imports.transforms.add('PLURALS') + imports.helpers.add('VARIABLE_REFERENCE') + const args = [`VARIABLE_REFERENCE(${JSON.stringify(pt.selector)})`] + if (pt.map.length > 0) { + imports.transforms.add('REPLACE_IN_TEXT') + const replace = pt.map.map( + ({ from, to }) => + `${JSON.stringify(from)}: VARIABLE_REFERENCE(${JSON.stringify(to)})` + ) + args.push( + `lambda text: REPLACE_IN_TEXT(text, { ${replace.join(', ')} })` + ) + } + return `PLURALS(source, ${key}, ${args.join(', ')})` + } } throw new Error(`Unknown pattern transform ${pt.type}`) } diff --git a/lib/transform-js.js b/lib/transform-js.js index 9f6c1c6..ec99dfc 100644 --- a/lib/transform-js.js +++ b/lib/transform-js.js @@ -214,6 +214,8 @@ For more information, see: https://github.com/mozilla/properties-to-ftl#readme if (keySrc !== key && ftlMsg.attr) { ftlKey += '.' + ftlMsg.attr fixmeNodes.add(keySrc) + } else if (ftlMsg.plural) { + fixmeNodes.add(keySrc) } if (propData.hasMigrateConfig) keySrc.value = ftlKey msgKeyLiterals.set(keySrc, ftlMsg)