Add PLURALS transform support
This commit is contained in:
Родитель
34ee912d1b
Коммит
c95f8aa0df
|
@ -5,14 +5,17 @@ import {
|
||||||
Message,
|
Message,
|
||||||
Pattern,
|
Pattern,
|
||||||
Placeable,
|
Placeable,
|
||||||
|
SelectExpression,
|
||||||
TextElement,
|
TextElement,
|
||||||
VariableReference
|
VariableReference,
|
||||||
|
Variant
|
||||||
} from '@fluent/syntax'
|
} from '@fluent/syntax'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ type: 'COPY', source: string }} CopyTransform
|
* @typedef {{ type: 'COPY', source: string }} CopyTransform
|
||||||
* @typedef {{ type: 'REPLACE', source: string, target: string, map: Array<{ from: string, to: string }> }} ReplaceTransform
|
* @typedef {{ type: 'REPLACE', source: string, map: Array<{ from: string, to: string }> }} ReplaceTransform
|
||||||
* @typedef { CopyTransform | ReplaceTransform } PatternTransform
|
* @typedef {{ type: 'PLURALS', source: string, selector: string, map: Array<{ from: string, to: string }> }} PluralsTransform
|
||||||
|
* @typedef { CopyTransform | ReplaceTransform | PluralsTransform } PatternTransform
|
||||||
*
|
*
|
||||||
* @typedef {{
|
* @typedef {{
|
||||||
* id: string,
|
* id: string,
|
||||||
|
@ -34,14 +37,9 @@ import {
|
||||||
* @param {Comment | null} comment
|
* @param {Comment | null} comment
|
||||||
* @param {import('./migrate-message').MessageMigration} migration
|
* @param {import('./migrate-message').MessageMigration} migration
|
||||||
*/
|
*/
|
||||||
export function addFluentPattern(
|
export function addFluentPattern(ftl, transforms, node, comment, migration) {
|
||||||
ftl,
|
const { key, attr } = migration
|
||||||
transforms,
|
/** @type {Message | undefined} */
|
||||||
node,
|
|
||||||
comment,
|
|
||||||
{ key, attr, varNames }
|
|
||||||
) {
|
|
||||||
/** @type {Message} */
|
|
||||||
let msg = ftl.body.find(
|
let msg = ftl.body.find(
|
||||||
(entry) => entry.type === 'Message' && entry.id.name === key
|
(entry) => entry.type === 'Message' && entry.id.name === key
|
||||||
)
|
)
|
||||||
|
@ -59,16 +57,22 @@ export function addFluentPattern(
|
||||||
let cc = comment?.content || ''
|
let cc = comment?.content || ''
|
||||||
let vc = 'Variables:'
|
let vc = 'Variables:'
|
||||||
let hasVar = false
|
let hasVar = false
|
||||||
const pattern = parseMsgPattern(node, varNames, (prev, next) => {
|
const pattern = parseMessage(
|
||||||
hasVar = true
|
node,
|
||||||
let desc = 'FIXME'
|
migration,
|
||||||
const re = new RegExp(`${prev.replace('$', '\\$')}([^%\n]+)`)
|
(/** @type {string} */ prev, /** @type {string} */ next) => {
|
||||||
cc = cc.replace(re, (_, prevDesc) => {
|
hasVar = true
|
||||||
desc = prevDesc.replace(/^\s*(is\s*)?/, '').replace(/\s*[;,.]?\s*$/, '.')
|
let desc = 'FIXME'
|
||||||
return ''
|
const re = new RegExp(`${prev.replace('$', '\\$')}([^%\n]+)`)
|
||||||
})
|
cc = cc.replace(re, (_, prevDesc) => {
|
||||||
vc += `\n $${next} (String): ${desc}`
|
desc = prevDesc
|
||||||
})
|
.replace(/^\s*(is\s*)?/, '')
|
||||||
|
.replace(/\s*[;,.]?\s*$/, '.')
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
vc += `\n $${next} (String): ${desc}`
|
||||||
|
}
|
||||||
|
)
|
||||||
if (hasVar) {
|
if (hasVar) {
|
||||||
const fc = (cc.replace(/\s+\n|\s*$/g, '\n') + vc).trim()
|
const fc = (cc.replace(/\s+\n|\s*$/g, '\n') + vc).trim()
|
||||||
if (comment) comment.content = fc
|
if (comment) comment.content = fc
|
||||||
|
@ -92,18 +96,69 @@ export function addFluentPattern(
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('dot-properties').Pair} node
|
* @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 {string[]} varNames
|
||||||
* @param {Function} fixArgNameInComment
|
* @param {Function} fixArgNameInComment
|
||||||
*/
|
*/
|
||||||
function parseMsgPattern({ key, value }, varNames, fixArgNameInComment) {
|
function parseMsgPattern(source, varNames, fixArgNameInComment) {
|
||||||
|
/** @type {Array<Placeable|TextElement>} */
|
||||||
const elements = []
|
const elements = []
|
||||||
let done = 0
|
let done = 0
|
||||||
let num = 0
|
let num = 0
|
||||||
/** @type {ReplaceTransform['map']} */
|
/** @type {ReplaceTransform['map']} */
|
||||||
const 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)
|
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
|
num = match[1] ? parseInt(match[1]) : num + 1
|
||||||
const name = varNames?.[num - 1] || `var${num}`
|
const name = varNames?.[num - 1] || `var${num}`
|
||||||
const id = new Identifier(name)
|
const id = new Identifier(name)
|
||||||
|
@ -112,13 +167,7 @@ function parseMsgPattern({ key, value }, varNames, fixArgNameInComment) {
|
||||||
done = match.index + match[0].length
|
done = match.index + match[0].length
|
||||||
fixArgNameInComment(match[0], name)
|
fixArgNameInComment(match[0], name)
|
||||||
}
|
}
|
||||||
if (done < value.length) elements.push(new TextElement(value.substring(done)))
|
if (done < source.length)
|
||||||
|
elements.push(new TextElement(source.substring(done)))
|
||||||
/** @type {PatternTransform} */
|
return { ftl: new Pattern(elements), map }
|
||||||
const transform =
|
|
||||||
Object.keys(map).length === 0
|
|
||||||
? { type: 'COPY', source: key }
|
|
||||||
: { type: 'REPLACE', source: key, map }
|
|
||||||
|
|
||||||
return { ftl: new Pattern(elements), transform }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,12 @@
|
||||||
import kebabCase from 'lodash.kebabcase'
|
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}
|
* @returns {MessageMigration}
|
||||||
*/
|
*/
|
||||||
export function migrateMessage(propData, propKey, varNames) {
|
export function migrateMessage(propData, propKey, varNames) {
|
||||||
if (!varNames) varNames = initVarNames(propData, propKey)
|
const { ast, migrate } = propData
|
||||||
const { 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]
|
const prev = migrate[propKey]
|
||||||
if (prev) {
|
if (prev) {
|
||||||
for (let i = prev.varNames.length; i < varNames.length; ++i) {
|
for (let i = prev.varNames.length; i < varNames.length; ++i) {
|
||||||
|
@ -49,16 +58,15 @@ export function migrateMessage(propData, propKey, varNames) {
|
||||||
return {
|
return {
|
||||||
key: getFtlKey(propData, key),
|
key: getFtlKey(propData, key),
|
||||||
attr: attr ? kebabCase(attr) : null,
|
attr: attr ? kebabCase(attr) : null,
|
||||||
|
plural: propNode?.value?.includes(';') ? 'FIXME' : null,
|
||||||
varNames
|
varNames
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('./parse-message-files.js').PropData} propData
|
* @param {import('dot-properties').Pair | undefined} node
|
||||||
* @param {string} propKey
|
|
||||||
*/
|
*/
|
||||||
function initVarNames({ ast }, propKey) {
|
function initVarNames(node) {
|
||||||
const node = ast.find((node) => node.type === 'PAIR' && node.key === propKey)
|
|
||||||
/** @type {string[]} */
|
/** @type {string[]} */
|
||||||
const varNames = []
|
const varNames = []
|
||||||
if (node) {
|
if (node) {
|
||||||
|
@ -78,7 +86,7 @@ function initVarNames({ ast }, propKey) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('./parse-message-files.js').PropData} propData
|
* @param {import('./parse-message-files.js').PropData} propData
|
||||||
* @param {string} propKey
|
* @param {string} key
|
||||||
* @returns {string}
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
function getFtlKey({ migrate, ftlPrefix, ftl }, key) {
|
function getFtlKey({ migrate, ftlPrefix, ftl }, key) {
|
||||||
|
|
|
@ -12,9 +12,11 @@ export const configPath = (propPath) =>
|
||||||
propPath.replace(/properties$/, 'migration.yaml')
|
propPath.replace(/properties$/, 'migration.yaml')
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param {string} root
|
||||||
* @param {string} propPath - Path to `.properties` file
|
* @param {string} propPath - Path to `.properties` file
|
||||||
* @returns {Promise<{
|
* @returns {Promise<{
|
||||||
* ftl: { root: string, path: string },
|
* ftl: { root: string, path: string },
|
||||||
|
* meta: { bug: string, title: string },
|
||||||
* migrate: Record<string, import('./migrate-message.js').MessageMigration>
|
* migrate: Record<string, import('./migrate-message.js').MessageMigration>
|
||||||
* } | null>}
|
* } | null>}
|
||||||
*/
|
*/
|
||||||
|
@ -30,6 +32,13 @@ export async function readMigrationConfig(root, propPath) {
|
||||||
const relPath = relative(root, cfgPath)
|
const relPath = relative(root, cfgPath)
|
||||||
console.warn(`Found migration config at ${relPath}`)
|
console.warn(`Found migration config at ${relPath}`)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {{
|
||||||
|
* ftl?: { root: string, path: string },
|
||||||
|
* meta?: { bug: string, title: string },
|
||||||
|
* migrate: Record<string, import('./migrate-message.js').MessageMigration>
|
||||||
|
* }}
|
||||||
|
*/
|
||||||
const { ftl, meta, migrate } = parse(src, { schema: 'failsafe' })
|
const { ftl, meta, migrate } = parse(src, { schema: 'failsafe' })
|
||||||
if (
|
if (
|
||||||
!meta ||
|
!meta ||
|
||||||
|
@ -73,9 +82,20 @@ export async function readMigrationConfig(root, propPath) {
|
||||||
!Array.isArray(value.varNames)
|
!Array.isArray(value.varNames)
|
||||||
) {
|
) {
|
||||||
throw new Error(
|
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('./parse-message-files.js').PropData} propData
|
||||||
* @param {import('./transform-js.js').TransformOptions} options
|
* @param {import('./transform-js.js').TransformOptions} options
|
||||||
* @returns {string} Path to migration config file
|
* @returns {Promise<string>} Path to migration config file
|
||||||
*/
|
*/
|
||||||
export async function writeMigrationConfig(propData, { all, bug, root }) {
|
export async function writeMigrationConfig(propData, { all, bug, root }) {
|
||||||
const { ast, ftlPath, ftlRoot, migrate, msgKeys, path } = propData
|
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'
|
if (!bug) doc.getIn(['meta', 'bug'], true).comment = ' FIXME'
|
||||||
for (const pair of doc.contents.items) pair.key.spaceBefore = true
|
for (const pair of doc.contents.items) pair.key.spaceBefore = true
|
||||||
|
|
||||||
|
/** @type {string[]} */
|
||||||
const fixVars = []
|
const fixVars = []
|
||||||
doc.get('migrate').items = msgKeys.map((propKey) => {
|
doc.get('migrate').items = msgKeys.map((propKey) => {
|
||||||
|
/** @type {import('yaml').Pair} */
|
||||||
let pair
|
let pair
|
||||||
|
/** @type {import('./migrate-message.js').MessageMigration | null} */
|
||||||
let mm = null
|
let mm = null
|
||||||
if (migrate.hasOwnProperty(propKey)) {
|
if (migrate.hasOwnProperty(propKey)) {
|
||||||
mm = migrate[propKey]
|
mm = migrate[propKey]
|
||||||
|
@ -131,6 +154,10 @@ export async function writeMigrationConfig(propData, { all, bug, root }) {
|
||||||
const mc = prettyMessageMigration(migrateMessage(propData, propKey, null))
|
const mc = prettyMessageMigration(migrateMessage(propData, propKey, null))
|
||||||
pair.value.commentBefore = stringify(mc).replace(/^/gm, ' ').trimEnd()
|
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) {
|
if (mm?.varNames) {
|
||||||
for (let i = 0; i < mm.varNames.length; ++i) {
|
for (let i = 0; i < mm.varNames.length; ++i) {
|
||||||
if (/^var\d+$/.test(mm.varNames[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
|
* @param {import('./migrate-message').MessageMigration} migration
|
||||||
*/
|
*/
|
||||||
function prettyMessageMigration({ key, attr, varNames }) {
|
function prettyMessageMigration({ key, attr, plural, varNames }) {
|
||||||
|
/** @type {Partial<import('./migrate-message.js').MessageMigration>} */
|
||||||
const value = { key }
|
const value = { key }
|
||||||
if (attr) value.attr = attr
|
if (attr) value.attr = attr
|
||||||
|
if (plural) value.plural = null
|
||||||
if (varNames.length > 0) value.varNames = varNames
|
if (varNames.length > 0) value.varNames = varNames
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { relative, resolve } from 'path'
|
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', '')
|
(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 {import('./add-fluent-pattern.js').PatternTransform} pt
|
||||||
* @param {{ helpers: Set<string>, transforms: Set<string>} imports
|
* @param {{ helpers: Set<string>, transforms: Set<string>}} imports
|
||||||
*/
|
*/
|
||||||
function compilePattern(pt, imports) {
|
function compilePattern(pt, imports) {
|
||||||
const key = JSON.stringify(pt.source)
|
const key = JSON.stringify(pt.source)
|
||||||
|
@ -74,6 +74,22 @@ function compilePattern(pt, imports) {
|
||||||
)
|
)
|
||||||
return `REPLACE(source, ${key}, { ${replace.join(', ')} })`
|
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}`)
|
throw new Error(`Unknown pattern transform ${pt.type}`)
|
||||||
}
|
}
|
||||||
|
|
|
@ -214,6 +214,8 @@ For more information, see: https://github.com/mozilla/properties-to-ftl#readme
|
||||||
if (keySrc !== key && ftlMsg.attr) {
|
if (keySrc !== key && ftlMsg.attr) {
|
||||||
ftlKey += '.' + ftlMsg.attr
|
ftlKey += '.' + ftlMsg.attr
|
||||||
fixmeNodes.add(keySrc)
|
fixmeNodes.add(keySrc)
|
||||||
|
} else if (ftlMsg.plural) {
|
||||||
|
fixmeNodes.add(keySrc)
|
||||||
}
|
}
|
||||||
if (propData.hasMigrateConfig) keySrc.value = ftlKey
|
if (propData.hasMigrateConfig) keySrc.value = ftlKey
|
||||||
msgKeyLiterals.set(keySrc, ftlMsg)
|
msgKeyLiterals.set(keySrc, ftlMsg)
|
||||||
|
|
Загрузка…
Ссылка в новой задаче