@lingui/babel-plugin-transform-react
Version:
Transform React components to ICU message format
434 lines (367 loc) • 12.8 kB
Flow
const pluralRules = ["zero", "one", "two", "few", "many", "other"]
const commonProps = ["id", "className", "render"]
// replace whitespace before/after newline with single space
const nlRe = /\s*(?:\r\n|\r|\n)+\s*/g
// remove whitespace before/after tag
const nlTagRe = /(?:(>)(?:\r\n|\r|\n)+\s+|(?:\r\n|\r|\n)+\s+(?=<))/g
function cleanChildren(node) {
node.children = []
node.openingElement.selfClosing = true
}
const mergeProps = (props, nextProps) => ({
text: props.text + nextProps.text,
values: Object.assign({}, props.values, nextProps.values),
components: props.components.concat(nextProps.components),
formats: props.formats,
elementIndex: nextProps.elementIndex
})
const initialProps = ({ formats } = {}) => ({
text: "",
values: {},
components: [],
formats: formats || {}
})
const generatorFactory = (index = 0) => () => index++
export default class Transformer {
constructor({ types: t }) {
this.t = t
this.isTransElement = this.elementName("Trans")
}
getOriginalImportName(local) {
// Either find original import name or use local one
const original = Object.keys(this.importDeclarations).filter(
name => this.importDeclarations[name] === local
)[0]
return original || local
}
getLocalImportName(name, strict = false) {
return this.importDeclarations[name] || (!strict && name)
}
isIdAttribute(node) {
return (
this.t.isJSXAttribute(node) &&
this.t.isJSXIdentifier(node.name, { name: "id" })
)
}
isDefaultsAttribute(node) {
return (
this.t.isJSXAttribute(node) &&
this.t.isJSXIdentifier(node.name, { name: "defaults" })
)
}
isDescriptionAttribute(node) {
return (
this.t.isJSXAttribute(node) &&
this.t.isJSXIdentifier(node.name, { name: "description" })
)
}
elementName = name => node =>
this.t.isJSXElement(node) &&
this.t.isJSXIdentifier(node.openingElement.name, {
name: this.getLocalImportName(name, true)
})
isChooseElement = node =>
this.elementName("Plural")(node) ||
this.elementName("Select")(node) ||
this.elementName("SelectOrdinal")(node)
isFormatElement = node =>
this.elementName("DateFormat")(node) ||
this.elementName("NumberFormat")(node)
processElement(node, file, props, root = false) {
const t = this.t
const element = node.openingElement
// Trans
if (this.isTransElement(node)) {
for (const child of node.children) {
props = this.processChildren(child, file, props)
}
// Plural, Select, SelectOrdinal
} else if (this.isChooseElement(node)) {
const componentName = this.getOriginalImportName(element.name.name)
if (node.children.length) {
throw file.buildCodeFrameError(
element,
`Children of ${componentName} aren't allowed.`
)
}
const choicesType = componentName.toLowerCase()
const choices = {}
let variable
let offset = ""
for (const attr of element.attributes) {
const {
name: { name }
} = attr
if (name === "value") {
const exp = t.isLiteral(attr.value)
? attr.value
: attr.value.expression
variable = t.isIdentifier(exp) ? exp.name : this.argumentGenerator()
const key = t.isIdentifier(exp) ? exp : t.numericLiteral(variable)
props.values[variable] = t.objectProperty(key, exp)
} else if (commonProps.includes(name)) {
// just do nothing
} else if (choicesType !== "select" && name === "offset") {
// offset is static parameter, so it must be either string or number
const offsetExp = t.isStringLiteral(attr.value)
? attr.value
: attr.value.expression
if (offsetExp.value === undefined) {
throw file.buildCodeFrameError(
element,
"Offset argument cannot be a variable."
)
}
offset = ` offset:${offsetExp.value}`
} else {
props = this.processChildren(
attr.value,
file,
Object.assign({}, props, { text: "" })
)
choices[name.replace("_", "=")] = props.text
}
}
// missing value
if (variable === undefined) {
throw file.buildCodeFrameError(element, "Value argument is missing.")
}
const choicesKeys = Object.keys(choices)
// 'other' choice is required
if (!choicesKeys.length) {
throw file.buildCodeFrameError(
element,
`Missing ${choicesType} choices. At least fallback argument 'other' is required.`
)
} else if (!choicesKeys.includes("other")) {
throw file.buildCodeFrameError(
element,
`Missing fallback argument 'other'.`
)
}
// validate plural rules
if (choicesType === "plural" || choicesType === "selectordinal") {
choicesKeys.forEach(rule => {
if (!pluralRules.includes(rule) && !/=\d+/.test(rule)) {
throw file.buildCodeFrameError(
element,
`Invalid plural rule '${rule}'. Must be ${pluralRules.join(
", "
)} or exact number depending on your source language ('one' and 'other' for English).`
)
}
})
}
const argument = choicesKeys
.map(form => `${form} {${choices[form]}}`)
.join(" ")
props.text = `{${variable}, ${choicesType},${offset} ${argument}}`
element.attributes = element.attributes.filter(attr =>
commonProps.includes(attr.name.name)
)
element.name = t.JSXIdentifier(this.getLocalImportName("Trans"))
} else if (this.isFormatElement(node)) {
if (root) {
// Don't convert standalone Format elements to ICU MessageFormat.
// It doesn't make sense to have `{name, number}` message, because we
// can call number() formatter directly in component.
return
}
const type = this.getOriginalImportName(element.name.name)
.toLowerCase()
.replace("format", "")
let variable, format
for (const attr of element.attributes) {
const {
name: { name }
} = attr
if (name === "value") {
const exp = t.isLiteral(attr.value)
? attr.value
: attr.value.expression
variable = t.isIdentifier(exp) ? exp.name : this.argumentGenerator()
const key = t.isIdentifier(exp) ? exp : t.numericLiteral(variable)
props.values[variable] = t.objectProperty(key, exp)
} else if (name === "format") {
if (t.isStringLiteral(attr.value)) {
format = attr.value.value
} else if (t.isJSXExpressionContainer(attr.value)) {
const exp = attr.value.expression
if (t.isStringLiteral(exp)) {
format = exp.value
} else if (t.isObjectExpression(exp) || t.isIdentifier(exp)) {
if (t.isIdentifier(exp)) {
format = exp.name
} else {
const formatName = new RegExp(`^${type}\\d+$`)
const existing = Object.keys(props.formats).filter(name =>
formatName.test(name)
)
format = `${type}${existing.length || 0}`
}
props.formats[format] = t.objectProperty(
t.identifier(format),
exp
)
}
}
if (!format) {
throw file.buildCodeFrameError(
element,
"Format can be either string for buil-in formats, variable or object for custom defined formats."
)
}
}
}
// missing value
if (variable === undefined) {
throw file.buildCodeFrameError(element, "Value argument is missing.")
}
const parts = [variable, type]
if (format) parts.push(format)
props.text = `{${parts.join(",")}}`
element.attributes = element.attributes.filter(attr =>
commonProps.includes(attr.name.name)
)
element.name = t.JSXIdentifier(this.getLocalImportName("Trans"))
// Other elements
} else {
if (root) return
const index = this.elementGenerator()
const selfClosing = node.openingElement.selfClosing
props.text += !selfClosing ? `<${index}>` : `<${index}/>`
for (const child of node.children) {
props = this.processChildren(child, file, props)
}
if (!selfClosing) props.text += `</${index}>`
cleanChildren(node)
props.components.unshift(node)
}
return props
}
processChildren(node, file, props) {
const t = this.t
let nextProps = initialProps({ formats: props.formats })
if (t.isJSXExpressionContainer(node)) {
const exp = node.expression
if (t.isStringLiteral(exp)) {
nextProps.text += exp.value
} else if (t.isTemplateLiteral(exp)) {
let parts = []
exp.quasis.forEach((item, index) => {
parts.push(item)
if (!item.tail) parts.push(exp.expressions[index])
})
parts.forEach(item => {
if (t.isTemplateElement(item)) {
nextProps.text += item.value.raw
} else {
const name = t.isIdentifier(item)
? item.name
: this.argumentGenerator()
const key = t.isIdentifier(item) ? item : t.numericLiteral(name)
nextProps.text += `{${name}}`
nextProps.values[name] = t.objectProperty(key, item)
}
})
} else if (t.isJSXElement(exp)) {
nextProps = this.processElement(exp, file, nextProps)
} else {
const name = t.isIdentifier(exp) ? exp.name : this.argumentGenerator()
const key = t.isIdentifier(exp) ? exp : t.numericLiteral(name)
nextProps.text += `{${name}}`
nextProps.values[name] = t.objectProperty(key, exp)
}
} else if (t.isJSXElement(node)) {
nextProps = this.processElement(node, file, nextProps)
} else if (t.isJSXSpreadChild(node)) {
// TODO: I don't have a clue what's the usecase
} else {
nextProps.text += node.value
}
return mergeProps(props, nextProps)
}
/**
* Used for macro
* @param imports
*/
setImportDeclarations(imports) {
// Used for the macro to override the imports
this.importDeclarations = imports
}
getImportDeclarations() {
return this.importDeclarations
}
transform = (path, file) => {
if (
!this.importDeclarations ||
!Object.keys(this.importDeclarations).length
) {
return
}
const { node } = path
const t = this.t
this.elementGenerator = generatorFactory()
this.argumentGenerator = generatorFactory()
// 1. Collect all parameters and inline elements and generate message ID
const props = this.processElement(
node,
file,
initialProps(),
/* root= */ true
)
if (!props) return
// 2. Replace children and add collected data
cleanChildren(node)
const text = props.text
.replace(nlTagRe, "$1")
.replace(nlRe, " ")
.trim()
let attrs = node.openingElement.attributes
// If `id` prop already exists and generated ID is different,
// add it as a `default` prop
const idAttr = attrs.filter(this.isIdAttribute.bind(this))[0]
if (idAttr && text && idAttr.value.value !== text) {
attrs.push(
t.JSXAttribute(t.JSXIdentifier("defaults"), t.StringLiteral(text))
)
} else if (!idAttr) {
attrs.push(t.JSXAttribute(t.JSXIdentifier("id"), t.StringLiteral(text)))
}
// Parameters for variable substitution
const valuesList = Object.values(props.values)
if (valuesList.length) {
attrs.push(
t.JSXAttribute(
t.JSXIdentifier("values"),
t.JSXExpressionContainer(t.objectExpression(valuesList))
)
)
}
// Inline elements
if (props.components.length) {
attrs.push(
t.JSXAttribute(
t.JSXIdentifier("components"),
t.JSXExpressionContainer(t.arrayExpression(props.components))
)
)
}
// Custom formats
const formatsList = Object.values(props.formats)
if (formatsList.length) {
attrs.push(
t.JSXAttribute(
t.JSXIdentifier("formats"),
t.JSXExpressionContainer(t.objectExpression(formatsList))
)
)
}
if (process.env.NODE_ENV === "production") {
node.openingElement.attributes = attrs.filter(
node =>
!this.isDefaultsAttribute(node) && !this.isDescriptionAttribute(node)
)
}
}
}