fluent-transpiler
Version:
Transpile Fluent (ftl) files into optimized, tree-shakable, JavaScript EcmaScript Modules (esm).
460 lines (440 loc) • 13.6 kB
JavaScript
import { parse } from '@fluent/syntax'
import { camelCase, pascalCase, constantCase, snakeCase } from 'change-case'
const exportDefault = `(id, params) => {
const source = __exports[id] ?? __exports['_'+id]
if (typeof source === 'undefined') return '*** '+id+' ***'
if (typeof source === 'function') return source(params)
return source
}
`
export const compile = (src, opts) => {
const options = {
comments: true,
errorOnJunk: true,
includeMessages: [],
excludeMessages: [],
//treeShaking: false,
variableNotation: 'camelCase',
disableMinify: false, // TODO needs better name strictInterface?
useIsolating: false,
params: 'params',
exportDefault,
...opts
}
if (!Array.isArray(options.locale)) options.locale = [options.locale]
if (!Array.isArray(options.includeMessages))
options.includeMessages = [options.includeMessages]
if (!Array.isArray(options.excludeMessages))
options.excludeMessages = [options.excludeMessages]
const metadata = {}
const exports = []
const functions = {} // global functions
let variable
const regexpValidVariable = /^[a-zA-Z]+[a-zA-Z0-9]*$/
const compileAssignment = (data) => {
variable = compileType(data)
metadata[variable] = {
id: data.name,
term: false,
params: false
}
return variable
}
const compileFunctionArguments = (data) => {
const positional = data.arguments?.positional.map((data) => {
return types[data.type](data)
})
const named = data.arguments?.named.reduce((obj, data) => {
// NamedArgument
const key = data.name.name
const value = compileType(data.value, data.type)
obj[key] = value
return obj
}, {})
return { positional, named }
}
const compileType = (data, parent) => {
try {
return types[data.type](data, parent)
} catch (e) {
console.error('Error:', e.message, data, e.stack)
throw new Error(e.message, { cause: data, stack: e.stack })
}
}
const types = {
Identifier: (data, parent) => {
const value =
parent === 'Attribute'
? data.name
: variableNotation[options.variableNotation](data.name)
if (value.includes('-')) {
return `'${value}'`
}
// Check for reserved words - TODO add in rest
if (['const', 'default', 'enum', 'if'].includes(value)) {
return '_' + value
}
return value
},
Attribute: (data) => {
const key = compileType(data.id, data.type)
const value = compileType(data.value, data.type)
return ` ${key}: ${value}`
},
Pattern: (data, parent) => {
return (
'`' +
data.elements
.map((data) => {
return compileType(data, parent)
})
.join('') +
'`'
)
},
// resources
Term: (data) => {
const assignment = compileAssignment(data.id)
const templateStringLiteral = compileType(data.value)
metadata[assignment].term = true
if (metadata[assignment].params) {
return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`
}
return `const ${assignment} = ${templateStringLiteral}\n`
},
Message: (data) => {
const assignment = compileAssignment(data.id)
if (
options.includeMessages.length &&
!options.includeMessages.includes(assignment)
) {
return ''
}
if (
options.excludeMessages.length &&
options.excludeMessages.includes(assignment)
) {
return ''
}
const templateStringLiteral =
data.value && compileType(data.value, data.type)
metadata[assignment].attributes = data.attributes.length
let attributes = {}
if (metadata[assignment].attributes) {
// use Object.create(null) ?
attributes = `{\n${data.attributes
.map((data) => {
return ' ' + compileType(data)
})
.join(',\n')}\n }`
}
//
let message = ''
if (!options.disableMinify) {
if (metadata[assignment].attributes) {
if (metadata[assignment].params) {
message = `(${options.params}) => ({
value:${templateStringLiteral},
attributes:${attributes}
})\n`
} else {
message = `{
value: ${templateStringLiteral},
attributes: ${attributes}
}\n`
}
} else if (metadata[assignment].params) {
message = `(${options.params}) => ${templateStringLiteral}\n`
} else {
message = `${templateStringLiteral}\n`
}
} else {
// consistent API
message = `(${metadata[assignment].params ? options.params : ''}) => ({
value:${templateStringLiteral},
attributes:${attributes}
})\n`
}
//if (options.treeShaking) {
if (assignment === metadata[assignment].id) {
exports.push(`${assignment}`)
} else {
exports.push(`'${metadata[assignment].id}': ${assignment}`)
}
return `export const ${assignment} = ${message}`
/*} else {
if (assignment === metadata[assignment].id) {
exports.push(`${assignment}: ${message}`)
} else {
exports.push(`'${metadata[assignment].id}': ${message}`)
}
}*/
return ''
},
Comment: (data) => {
if (options.comments) return `// # ${data.content}\n`
return ''
},
GroupComment: (data) => {
if (options.comments) return `// ## ${data.content}\n`
return ''
},
ResourceComment: (data) => {
if (options.comments) return `// ### ${data.content}\n`
return ''
},
Junk: (data) => {
if (options.errorOnJunk) {
throw new Error('Junk found', { cause: data })
}
console.error('Error: Skipping Junk', JSON.stringify(data, null, 2))
return ''
},
// Element
TextElement: (data) => {
return data.value.replaceAll('`', '\\`') // escape string literal
},
Placeable: (data, parent) => {
return `${options.useIsolating ? '\u2068' : ''}\${${compileType(
data.expression,
parent
)}}${options.useIsolating ? '\u2069' : ''}`
},
// Expression
StringLiteral: (data, parent) => {
// JSON.stringify at parent level
if (['NamedArgument'].includes(parent)) {
return `${data.value}`
}
return `"${data.value}"`
},
NumberLiteral: (data) => {
const decimal = Number.parseFloat(data.value)
const number = Number.isInteger(decimal)
? Number.parseInt(data.value)
: decimal
return Intl.NumberFormat(options.locale).format(number)
},
VariableReference: (data, parent) => {
functions.__formatVariable = true
metadata[variable].params = true
const value = `${options.params}?.${data.id.name}`
if (['Message', 'Variant', 'Attribute'].includes(parent)) {
return `__formatVariable(${value})`
}
return value
},
MessageReference: (data) => {
const messageName = compileType(data.id)
metadata[variable].params ||= metadata[messageName].params
if (!options.disableMinify) {
if (metadata[messageName].params) {
return `${messageName}(${options.params})`
}
return `${messageName}`
}
return `${messageName}(${
metadata[messageName].params ? options.params : ''
})`
},
TermReference: (data) => {
const termName = compileType(data.id)
metadata[variable].params ||= metadata[termName].params
let params
if (metadata[termName].params) {
let { named } = compileFunctionArguments(data)
named = JSON.stringify(named)
if (named) {
params = `{ ...${options.params}, ${named.substring(
1,
named.length - 1
)} }`
} else {
params = options.params
}
}
if (!options.disableMinify) {
if (metadata[termName].params) {
return `${termName}(${params})`
}
return `${termName}`
}
return `${termName}(${params ? params : ''})`
},
NamedArgument: (data) => {
// Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
const key = data.name.name // Don't transform value
const value = compileType(data.value, data.type)
return `${key}: ${value}`
},
SelectExpression: (data) => {
functions.__select = true
metadata[variable].params = true
const value = compileType(data.selector)
//const options = data.selector
let fallback
return `__select(\n ${value},\n {\n${data.variants
.filter((data) => {
if (data.default) {
fallback = compileType(data.value, data.type)
}
return !data.default
})
.map((data) => {
return ' ' + compileType(data)
})
.join(',\n')}\n },\n ${fallback}\n )`
},
Variant: (data, parent) => {
// Inconsistent: `Variant` uses `key` instead of `id` for Identifier
const key = compileType(data.key)
const value = compileType(data.value, data.type)
return ` '${key}': ${value}`
},
FunctionReference: (data) => {
return `${types[data.id.name](compileFunctionArguments(data))}`
},
// Functions
DATETIME: (data) => {
functions.__formatDateTime = true
const { positional, named } = data
const value = positional.shift()
return `__formatDateTime(${value}, ${JSON.stringify(named)})`
},
RELATIVETIME: (data) => {
functions.__formatRelativeTime = true
const { positional, named } = data
const value = positional.shift()
return `__formatRelativeTime(${value}, ${JSON.stringify(named)})`
},
NUMBER: (data) => {
functions.__formatNumber = true
const { positional, named } = data
const value = positional.shift()
return `__formatNumber(${value}, ${JSON.stringify(named)})`
}
}
if (/\t/.test(src)) {
console.error(
'Source file contains tab characters (\t), replacing with <space>x4'
)
src = src.replace(/\t/g, ' ')
}
const { body } = parse(src)
let translations = ``
for (const data of body) {
translations += compileType(data)
}
let output = ``
if (
functions.__formatVariable ||
functions.__formatDateTime ||
functions.__formatNumber
) {
output += `const __locales = ${JSON.stringify(opts.locale)}\n`
}
/*
const relativeTimeFormat = new Intl.RelativeTimeFormat(lang, {
localeMatcher: 'best fit',
numeric: 'always',
style: 'long'
})
const formatTime = (value) => {
value = new Date(value)
if (isNaN(value.getTime())) return value
try {
const [duration, unit] = relativeTimeDiff(value)
return relativeTimeFormat.format(duration, unit)
} catch (e) {
return dateTimeFormat.format(value)
}
}
*/
if (functions.__formatRelativeTime) {
output += `
const __relativeTimeDiff = (d) => {
const msPerMinute = 60 * 1000
const msPerHour = msPerMinute * 60
const msPerDay = msPerHour * 24
const msPerWeek = msPerDay * 7
const msPerMonth = msPerDay * 30
const msPerYear = msPerDay * 365.25
const elapsed = d - new Date()
if (Math.abs(elapsed) < msPerMinute) {
return [Math.round(elapsed / 1000), 'second']
}
if (Math.abs(elapsed) < msPerHour) {
return [Math.round(elapsed / msPerMinute), 'minute']
}
if (Math.abs(elapsed) < msPerDay) {
return [Math.round(elapsed / msPerHour), 'hour']
}
if (Math.abs(elapsed) < msPerWeek * 2) {
return [Math.round(elapsed / msPerDay), 'day']
}
if (Math.abs(elapsed) < msPerMonth) {
return [Math.round(elapsed / msPerWeek), 'week']
}
if (Math.abs(elapsed) < msPerYear) {
return [Math.round(elapsed / msPerMonth), 'month']
}
return [Math.round(elapsed / msPerYear), 'year']
}
const __formatRelativeTime = (value, options) => {
if (typeof value === 'string') value = new Date(value)
if (isNaN(value.getTime())) return value
try {
const [duration, unit] = __relativeTimeDiff(value)
return new Intl.RelativeTimeFormat(__locales, options).format(duration, unit)
} catch (e) {}
return new Intl.DateTimeFormat(__locales, options).format(value)
}
`
}
if (functions.__formatDateTime) {
output += `
const __formatDateTime = (value, options) => {
if (typeof value === 'string') value = new Date(value)
if (isNaN(value.getTime())) return value
return new Intl.DateTimeFormat(__locales, options).format(value)
}
`
}
if (functions.__formatVariable || functions.__formatNumber) {
output += `
const __formatNumber = (value, options) => {
return new Intl.NumberFormat(__locales, options).format(value)
}
`
}
if (functions.__formatVariable) {
output += `
const __formatVariable = (value) => {
if (typeof value === 'string') return value
const decimal = Number.parseFloat(value)
const number = Number.isInteger(decimal) ? Number.parseInt(value) : decimal
return __formatNumber(number)
}
`
}
if (functions.__select) {
output += `
const __select = (value, cases, fallback, options) => {
const pluralRules = new Intl.PluralRules(__locales, options)
const rule = pluralRules.select(value)
return cases[value] ?? cases[rule] ?? fallback
}
`
}
output += `\n` + translations
output += `const __exports = {\n ${exports.join(',\n ')}\n}`
output += `\nexport default ${options.exportDefault}`
return output
}
const variableNotation = {
camelCase,
pascalCase,
snakeCase,
constantCase
}
export default compile