├── .gitignore ├── packages ├── runtime │ ├── .gitignore │ ├── src │ │ ├── index.js │ │ ├── intl-memo.js │ │ ├── bundle.test.js │ │ ├── runtime.js │ │ ├── bundle.js │ │ └── runtime.test.js │ ├── babel.config.js │ └── package.json ├── loader │ ├── test │ │ ├── fixtures │ │ │ ├── simple.ftl │ │ │ └── complex.ftl │ │ ├── es6-compiler.js │ │ ├── es5-compiler.js │ │ ├── end-to-end.test.js │ │ └── loader.test.js │ ├── babel.config.js │ ├── package.json │ ├── src │ │ └── loader.js │ └── README.md └── compiler │ ├── babel.config.js │ ├── package.json │ └── src │ ├── index.js │ ├── compiler.js │ └── compiler.test.js ├── lerna.json ├── .travis.yml ├── test ├── mocha.setup.js ├── compile-and-require.js ├── context.test.js ├── isolating.test.js ├── functions_builtin.test.js └── select_expressions.test.js ├── LICENSE ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | dist 3 | node_modules 4 | -------------------------------------------------------------------------------- /packages/runtime/.gitignore: -------------------------------------------------------------------------------- 1 | /bundle.* 2 | /intl-memo.* 3 | /runtime.* 4 | -------------------------------------------------------------------------------- /packages/loader/test/fixtures/simple.ftl: -------------------------------------------------------------------------------- 1 | hello-user = Hello, {$userName}! 2 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "0.1.0" 4 | } 5 | -------------------------------------------------------------------------------- /packages/runtime/src/index.js: -------------------------------------------------------------------------------- 1 | // Used by E2E tests 2 | export { Runtime } from './runtime' 3 | -------------------------------------------------------------------------------- /packages/compiler/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babelrc: false, 3 | presets: [['@babel/preset-env', { targets: { node: '8.9' } }]], 4 | plugins: ['@babel/plugin-proposal-object-rest-spread'] 5 | } 6 | -------------------------------------------------------------------------------- /packages/loader/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | babelrc: false, 3 | presets: [['@babel/preset-env', { loose: true, targets: { node: '8.9' } }]], 4 | plugins: [['@babel/plugin-proposal-object-rest-spread', { loose: true }]] 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'node' 4 | - 'lts/*' 5 | - '8.9.0' 6 | 7 | jobs: 8 | include: 9 | - stage: Prettier 10 | node_js: node 11 | script: 12 | - npx prettier --check packages/*/src/* test/* 13 | -------------------------------------------------------------------------------- /packages/runtime/babel.config.js: -------------------------------------------------------------------------------- 1 | const modules = process.env.ESM ? false : 'auto' 2 | 3 | module.exports = { 4 | babelrc: false, 5 | presets: [['@babel/preset-env', { loose: true, modules }]], 6 | plugins: ['@babel/plugin-proposal-object-rest-spread'] 7 | } 8 | -------------------------------------------------------------------------------- /test/mocha.setup.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('intl-pluralrules') 4 | require('@babel/register')({ 5 | ignore: [/node_modules/], 6 | plugins: [ 7 | '@babel/plugin-proposal-object-rest-spread', 8 | '@babel/plugin-transform-modules-commonjs' 9 | ] 10 | }) 11 | -------------------------------------------------------------------------------- /packages/loader/test/fixtures/complex.ftl: -------------------------------------------------------------------------------- 1 | # Simple things are simple. 2 | hello-user = Hello, {$userName}! 3 | 4 | # Complex things are possible. 5 | shared-photos = 6 | {$userName} {$photoCount -> 7 | [one] added a new photo 8 | *[other] added {$photoCount} new photos 9 | } to {$userGender -> 10 | [male] his stream 11 | [female] her stream 12 | *[other] their stream 13 | }. 14 | -------------------------------------------------------------------------------- /packages/runtime/src/intl-memo.js: -------------------------------------------------------------------------------- 1 | function memo(name, lc, opt) { 2 | let k = `${name} ${lc} ` 3 | if (opt) k += Object.entries(opt) 4 | return memo[k] || (memo[k] = new Intl[name](lc, opt)) 5 | } 6 | 7 | /** new Intl.DateTimeFormat(lc, opt).format(val) */ 8 | export const dtf = (lc, val, opt) => memo('DateTimeFormat', lc, opt).format(val) 9 | 10 | /** new Intl.NumberFormat(lc, opt).format(val) */ 11 | export const nf = (lc, val, opt) => memo('NumberFormat', lc, opt).format(val) 12 | 13 | /** new Intl.PluralRules(lc, opt).select(val) */ 14 | export const pr = (lc, val, opt) => memo('PluralRules', lc, opt).select(val) 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019 Eemeli Aro 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /test/compile-and-require.js: -------------------------------------------------------------------------------- 1 | import fs from 'fs' 2 | import path from 'path' 3 | import tmp from 'tmp' 4 | 5 | import { compile } from '../packages/compiler/src' 6 | 7 | export function compileAndRequire(locale, ftlSrc, asResource) { 8 | const runtimePath = path.resolve(__dirname, '../packages/runtime/src') 9 | const jsSrc = compile(locale, ftlSrc, { 10 | runtime: asResource ? 'resource' : 'bundle', 11 | runtimePath 12 | }) 13 | return new Promise((resolve, reject) => { 14 | tmp.file({ postfix: '.js' }, (err, path, fd) => { 15 | if (err) reject(err) 16 | else { 17 | fs.write(fd, jsSrc, 0, 'utf8', err => { 18 | if (err) reject(err) 19 | else resolve(require(path).default) 20 | }) 21 | } 22 | }) 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /packages/loader/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-loader", 3 | "version": "0.1.0", 4 | "author": "Eemeli Aro ", 5 | "license": "Apache-2.0", 6 | "description": "Webpack loader for Fluent", 7 | "keywords": [ 8 | "localization", 9 | "l10n", 10 | "internationalization", 11 | "i18n", 12 | "fluent", 13 | "ftl", 14 | "webpack", 15 | "loader" 16 | ], 17 | "homepage": "https://github.com/eemeli/fluent-loader#readme", 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/eemeli/fluent-loader.git", 21 | "directory": "packages/loader" 22 | }, 23 | "main": "dist/loader", 24 | "files": [ 25 | "dist/" 26 | ], 27 | "sideEffects": false, 28 | "scripts": { 29 | "build": "babel src/ -d dist/", 30 | "test": "jest --color" 31 | }, 32 | "engines": { 33 | "node": ">=8.9.0" 34 | }, 35 | "dependencies": { 36 | "fluent-compiler": "file:../compiler", 37 | "loader-utils": "^1.2.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/loader/test/es6-compiler.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import webpack from 'webpack' 3 | import memoryfs from 'memory-fs' 4 | 5 | export default (fixture, options) => { 6 | const compiler = webpack({ 7 | mode: 'none', 8 | context: __dirname, 9 | entry: `./${fixture}`, 10 | output: { 11 | path: path.resolve(__dirname), 12 | filename: 'bundle.js' 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.ftl$/, 18 | use: { 19 | loader: path.resolve(__dirname, '../src/loader.js'), 20 | options 21 | } 22 | } 23 | ] 24 | } 25 | }) 26 | compiler.outputFileSystem = new memoryfs() 27 | 28 | return new Promise((resolve, reject) => { 29 | compiler.run((err, stats) => { 30 | if (err) reject(err) 31 | else if (stats.hasErrors()) reject(new Error(stats.toJson().errors)) 32 | else { 33 | const { modules } = stats.toJson() 34 | const ftlModule = modules.find(mod => /\.ftl$/.test(mod.identifier)) 35 | resolve(ftlModule.source) 36 | } 37 | }) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /packages/compiler/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-compiler", 3 | "version": "0.1.0", 4 | "author": "Eemeli Aro ", 5 | "license": "Apache-2.0", 6 | "description": "JavaScript transpiler for Fluent messages", 7 | "keywords": [ 8 | "localization", 9 | "l10n", 10 | "internationalization", 11 | "i18n", 12 | "fluent", 13 | "ftl", 14 | "ast", 15 | "compiler", 16 | "transpiler" 17 | ], 18 | "homepage": "https://github.com/eemeli/fluent-compiler#readme", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/eemeli/fluent-compiler.git", 22 | "directory": "packages/compiler" 23 | }, 24 | "main": "dist/index", 25 | "files": [ 26 | "dist/" 27 | ], 28 | "sideEffects": false, 29 | "scripts": { 30 | "build": "babel src/ -d dist/ --ignore 'src/*.test.js'", 31 | "test": "mocha --color --ui tdd --require ../../test/mocha.setup src/*.test.js" 32 | }, 33 | "engines": { 34 | "node": ">=8.9.0" 35 | }, 36 | "dependencies": { 37 | "fluent-runtime": "file:../runtime", 38 | "fluent-syntax": "^0.13.0", 39 | "safe-identifier": "^0.1.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /packages/runtime/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-runtime", 3 | "version": "0.1.0", 4 | "author": "Eemeli Aro ", 5 | "license": "Apache-2.0", 6 | "description": "Runtime for fluent-compiler", 7 | "homepage": "https://github.com/eemeli/fluent-compiler#readme", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/eemeli/fluent-compiler.git", 11 | "directory": "packages/runtime" 12 | }, 13 | "main": "runtime", 14 | "files": [ 15 | "bundle.*", 16 | "intl-memo.*", 17 | "runtime.*" 18 | ], 19 | "sideEffects": false, 20 | "browserslist": "defaults", 21 | "scripts": { 22 | "build:esm:bundle": "ESM=1 babel src/bundle.js -o ./bundle.mjs", 23 | "build:esm:intl-memo": "ESM=1 babel src/intl-memo.js -o ./intl-memo.mjs", 24 | "build:esm:runtime": "ESM=1 babel src/runtime.js -o ./runtime.mjs", 25 | "build:esm": "npm run build:esm:bundle && npm run build:esm:intl-memo && npm run build:esm:runtime", 26 | "build:cjs": "babel src/ -d . --ignore 'src/*.test.js' --ignore src/index.js", 27 | "build": "npm run build:esm && npm run build:cjs", 28 | "test": "mocha --color --ui tdd --require ../../test/mocha.setup src/*.test.js" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/loader/test/es5-compiler.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import webpack from 'webpack' 3 | import memoryfs from 'memory-fs' 4 | 5 | export default (fixture, options) => { 6 | const compiler = webpack({ 7 | mode: 'none', 8 | context: __dirname, 9 | entry: `./${fixture}`, 10 | output: { 11 | path: path.resolve(__dirname), 12 | filename: 'bundle.js', 13 | libraryTarget: 'commonjs2' 14 | }, 15 | module: { 16 | rules: [ 17 | { 18 | test: /\.ftl$/, 19 | use: [ 20 | 'babel-loader', 21 | { 22 | loader: path.resolve(__dirname, '../src/loader.js'), 23 | options 24 | } 25 | ] 26 | } 27 | ] 28 | } 29 | }) 30 | const fs = new memoryfs() 31 | compiler.outputFileSystem = fs 32 | 33 | return new Promise((resolve, reject) => { 34 | compiler.run((err, stats) => { 35 | if (err) reject(err) 36 | else if (stats.hasErrors()) reject(new Error(stats.toJson().errors)) 37 | else { 38 | const { outputPath } = stats.toJson() 39 | const bundle = fs.readFileSync(`${outputPath}/bundle.js`, 'utf8') 40 | resolve(bundle) 41 | } 42 | }) 43 | }) 44 | } 45 | -------------------------------------------------------------------------------- /packages/runtime/src/bundle.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | import { FluentBundle } from './bundle' 4 | 5 | suite('Bundle runtime', function() { 6 | suite('Undefined locale uses system fallback', function() { 7 | test('Format message', function() { 8 | const value = () => ['Foo', 'Bar'] 9 | const bundle = new FluentBundle(undefined, new Map([['foo', { value }]])) 10 | const msg = bundle.getMessage('foo') 11 | assert.equal(bundle.formatPattern(msg.value), 'FooBar') 12 | }) 13 | 14 | test('Format message attribute', function() { 15 | const value = () => ['Foo'] 16 | const attributes = { bar: () => ['Bar'] } 17 | const bundle = new FluentBundle( 18 | undefined, 19 | new Map([['foo', { value, attributes }]]) 20 | ) 21 | const msg = bundle.getMessage('foo') 22 | assert.equal(bundle.formatPattern(msg.attributes.bar), 'Bar') 23 | }) 24 | }) 25 | 26 | suite('With set locale "fi"', function() { 27 | test('Format message', function() { 28 | const value = () => ['Foo', 'Bar'] 29 | const bundle = new FluentBundle(['fi'], new Map([['foo', { value }]])) 30 | const msg = bundle.getMessage('foo') 31 | assert.equal(bundle.formatPattern(msg.value), 'FooBar') 32 | }) 33 | }) 34 | }) 35 | -------------------------------------------------------------------------------- /packages/runtime/src/runtime.js: -------------------------------------------------------------------------------- 1 | import { dtf, nf, pr } from './intl-memo' 2 | 3 | export function Runtime(lc) { 4 | return { 5 | isol(expr) { 6 | // Unicode bidi isolation characters. 7 | const FSI = '\u2068' 8 | const PDI = '\u2069' 9 | return Array.isArray(expr) ? [FSI].concat(expr, PDI) : [FSI, expr, PDI] 10 | }, 11 | 12 | select(value, def, variants) { 13 | if (value && value.$) { 14 | if (variants.hasOwnProperty(value.fmt)) return variants[value.fmt] 15 | const cat = pr(lc, value.value, value.$) 16 | return variants.hasOwnProperty(cat) ? variants[cat] : variants[def] 17 | } 18 | if (variants.hasOwnProperty(value)) return variants[value] 19 | if (typeof value === 'number') { 20 | const cat = pr(lc, value) 21 | if (variants.hasOwnProperty(cat)) return variants[cat] 22 | } 23 | return variants[def] 24 | }, 25 | 26 | DATETIME($, value) { 27 | try { 28 | return dtf(lc, value instanceof Date ? value : new Date(value), $) 29 | } catch (e) { 30 | return e instanceof RangeError ? 'Invalid Date' : value 31 | } 32 | }, 33 | 34 | NUMBER($, value) { 35 | if (isNaN(value)) return 'NaN' 36 | try { 37 | return { $, value, fmt: nf(lc, value, $) } 38 | } catch (e) { 39 | return String(value) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/loader/src/loader.js: -------------------------------------------------------------------------------- 1 | import { compile } from 'fluent-compiler' 2 | import { getOptions } from 'loader-utils' 3 | 4 | /** 5 | * Matches file path parts (separated by [._/]) to known locales. Looks first 6 | * for full locale names, then for root locale names, e.g. first looking for 7 | * `en-US` and then for `en`. 8 | * 9 | * @param {Context} ctx The loader context object, available as its `this` 10 | * @param {string[]} locales Available locales 11 | * @returns {string} The identified locale, or `locales[0]` on failure 12 | */ 13 | function getLocale({ resourcePath, rootContext }, locales) { 14 | if (locales.length === 1) return locales[0] 15 | const parts = resourcePath 16 | .replace(rootContext, '') 17 | .split(/[._/]/) 18 | .filter(Boolean) 19 | .reverse() 20 | for (const part of parts) if (locales.includes(part)) return part 21 | const topLocales = locales.reduce((top, lc) => { 22 | const i = lc.indexOf('-') 23 | return i > 0 ? top.concat(lc.slice(0, i)) : top 24 | }, []) 25 | if (topLocales.length > 0) { 26 | for (const part of parts) if (topLocales.includes(part)) return part 27 | } 28 | return locales[0] 29 | } 30 | 31 | export default function(source) { 32 | const { locales = ['en-US'], ...options } = getOptions(this) || {} 33 | if (Array.isArray(locales) && locales.length > 0) { 34 | const lc = getLocale(this, locales) 35 | return compile([lc], source, options) 36 | } else { 37 | this.emitError(new Error('If set, `locales` must be a non-empty array')) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /packages/compiler/src/index.js: -------------------------------------------------------------------------------- 1 | import { FluentParser } from 'fluent-syntax' 2 | import { FluentCompiler } from './compiler' 3 | 4 | export { compile, FluentCompiler } 5 | 6 | /** 7 | * Compile a Fluent resource into the source of an ES6 module 8 | * 9 | * The output module default-exports an object providing the same interface as 10 | * [FluentBundle](http://projectfluent.org/fluent.js/fluent/class/src/bundle.js~FluentBundle.html) 11 | * except for its `addMessages` and `addResource` methods. 12 | * 13 | * @param {string | string[] | undefined} locales The resource's locale identifier 14 | * @param {string | Resource} source Fluent source as a string, or an AST compiled from it 15 | * @param {Object} [opts={}] Options passed to both FluentParser and FluentCompiler 16 | * @param {string[]} [opts.runtimeGlobals=['DATETIME', 'NUMBER']] Identifiers of global functions available in the runtime 17 | * @param {boolean} [opts.runtimePath='fluent-compiler/runtime'] Path for the runtime dependency 18 | * @param {boolean} [opts.useIsolating=true] Wrap placeables with Unicode FSI & PDI isolation marks 19 | * @param {boolean} [opts.withJunk=false] Include unparsed source as comments in the output 20 | * @returns {string} The source of an ES6 module exporting a FluentBundle implementation of the source 21 | */ 22 | function compile(locales, source, opts) { 23 | if (typeof source === 'string') { 24 | const parser = new FluentParser() 25 | source = parser.parse(source) 26 | } 27 | const compiler = new FluentCompiler(opts) 28 | return compiler.compile(locales, source) 29 | } 30 | -------------------------------------------------------------------------------- /packages/runtime/src/bundle.js: -------------------------------------------------------------------------------- 1 | import { dtf, nf } from './intl-memo' 2 | 3 | function msgString(lc, parts) { 4 | return parts 5 | .map(part => { 6 | switch (typeof part) { 7 | case 'string': 8 | return part 9 | case 'number': 10 | return nf(lc, part) 11 | case 'object': 12 | if (part instanceof Date) { 13 | return dtf(lc, part) 14 | } else if (Array.isArray(part)) { 15 | return msgString(lc, part) 16 | } else if (part && part.$) { 17 | return part.fmt 18 | } 19 | } 20 | return String(part) 21 | }) 22 | .join('') 23 | } 24 | 25 | export class FluentBundle { 26 | constructor(lc, resource) { 27 | this._res = resource 28 | this.locales = Array.isArray(lc) ? lc : [lc] 29 | } 30 | 31 | addResource(resource, opt) { 32 | const ao = (opt && opt.allowOverrides) || false 33 | const err = [] 34 | for (const [id, msg] of resource) { 35 | if (!ao && this._res.has(id)) { 36 | err.push(`Attempt to override an existing message: "${id}"`) 37 | } else { 38 | this._res.set(id, msg) 39 | } 40 | } 41 | return err 42 | } 43 | 44 | formatPattern(pattern, args, errors) { 45 | try { 46 | const parts = pattern(args || {}) 47 | return msgString(this.locales, parts) 48 | } catch (err) { 49 | if (errors) errors.push(err) 50 | return null 51 | } 52 | } 53 | 54 | getMessage(id) { 55 | return id[0] !== '-' && this._res.get(id) 56 | } 57 | 58 | hasMessage(id) { 59 | return id[0] !== '-' && this._res.has(id) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fluent-compiler-monorepo", 3 | "version": "0.1.0", 4 | "author": "Eemeli Aro ", 5 | "license": "Apache-2.0", 6 | "description": "Monorepo for fluent-compiler", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/eemeli/fluent-compiler.git" 10 | }, 11 | "scripts": { 12 | "build": "lerna run build", 13 | "clean": "git clean -fdx packages/", 14 | "prettier": "prettier --write *.js src/* test/*", 15 | "test:e2e": "mocha --ui tdd --require test/mocha.setup test/*.test.js", 16 | "pretest": "npm run build", 17 | "test": "lerna run test && npm run test:e2e" 18 | }, 19 | "prettier": { 20 | "semi": false, 21 | "singleQuote": true 22 | }, 23 | "dependencies": { 24 | "fluent-compiler": "file:packages/compiler", 25 | "fluent-loader": "file:packages/loader", 26 | "fluent-runtime": "file:packages/runtime" 27 | }, 28 | "devDependencies": { 29 | "@babel/cli": "^7.5.0", 30 | "@babel/core": "^7.5.4", 31 | "@babel/plugin-proposal-object-rest-spread": "^7.5.4", 32 | "@babel/plugin-transform-modules-commonjs": "^7.5.0", 33 | "@babel/preset-env": "^7.5.4", 34 | "@babel/register": "^7.4.4", 35 | "@fluent/dedent": "^0.1.0", 36 | "babel-jest": "^24.8.0", 37 | "babel-loader": "^8.0.6", 38 | "common-tags": "^2.0.0-alpha.1", 39 | "fluent-syntax": "^0.13.0", 40 | "intl-pluralrules": "^1.0.3", 41 | "jest": "^24.8.0", 42 | "lerna": "^3.15.0", 43 | "memory-fs": "^0.4.1", 44 | "mocha": "^6.1.4", 45 | "prettier": "^1.18.2", 46 | "tmp": "^0.1.0", 47 | "webpack": "^4.35.3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/loader/test/end-to-end.test.js: -------------------------------------------------------------------------------- 1 | import 'intl-pluralrules' // For Node 8 2 | import compiler from './es5-compiler' 3 | 4 | function getBundle(src) { 5 | const fn = new Function('module', src) 6 | const mod = {} 7 | fn(mod) 8 | expect(mod).toMatchObject({ exports: { default: {} } }) 9 | return mod.exports.default 10 | } 11 | 12 | test('Simple FTL', async () => { 13 | const js = await compiler('fixtures/simple.ftl', { useIsolating: false }) 14 | const bundle = getBundle(js) 15 | expect(typeof bundle.formatPattern).toBe('function') 16 | expect(typeof bundle.getMessage).toBe('function') 17 | const msg = bundle.getMessage('hello-user') 18 | expect(msg).toMatchObject({ value: {} }) 19 | 20 | let res, errors 21 | res = bundle.formatPattern(msg.value, { userName: 'USER' }, errors = []) 22 | expect(errors).toHaveLength(0) 23 | expect(res).toBe('Hello, USER!') 24 | 25 | res = bundle.formatPattern(msg.value, {}, errors = []) 26 | expect(errors).toHaveLength(0) 27 | expect(res).toBe('Hello, undefined!') 28 | 29 | res = bundle.formatPattern(msg.value, null, errors = []) 30 | expect(errors).toHaveLength(0) 31 | expect(res).toBe('Hello, undefined!') 32 | }) 33 | 34 | test('Complex FTL', async () => { 35 | const js = await compiler('fixtures/complex.ftl', { useIsolating: false }) 36 | const bundle = getBundle(js) 37 | expect(typeof bundle.formatPattern).toBe('function') 38 | expect(typeof bundle.getMessage).toBe('function') 39 | const testData = { 40 | photoCount: 1, 41 | userGender: 'female', 42 | userName: 'USER' 43 | } 44 | 45 | let msg, res, errors 46 | msg = bundle.getMessage('hello-user') 47 | res = bundle.formatPattern(msg.value, testData, errors = []) 48 | expect(errors).toHaveLength(0) 49 | expect(res).toBe('Hello, USER!') 50 | 51 | msg = bundle.getMessage('shared-photos') 52 | errors = [] 53 | res = bundle.formatPattern(msg.value, testData, errors = []) 54 | expect(errors).toHaveLength(0) 55 | expect(res).toBe('USER added a new photo to her stream.') 56 | }) 57 | -------------------------------------------------------------------------------- /packages/runtime/src/runtime.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | 3 | import { Runtime } from './runtime' 4 | 5 | suite('Runtime', function() { 6 | suite('Undefined locale uses system fallback', function() { 7 | let rt 8 | setup(function() { 9 | rt = new Runtime(undefined) 10 | }) 11 | 12 | test('Built-in DATETIME formatter', function() { 13 | const dtf = new Intl.DateTimeFormat() 14 | const d = new Date() 15 | assert.equal(rt.DATETIME({}, d), dtf.format(d)) 16 | }) 17 | 18 | test('Built-in NUMBER formatter', function() { 19 | const nf = new Intl.NumberFormat() 20 | const value = 1.2 21 | const fmt = nf.format(value) 22 | assert.deepEqual(rt.NUMBER({}, value), { $: {}, fmt, value }) 23 | }) 24 | 25 | test('Select with string', function() { 26 | assert.equal(rt.select('foo', 'bar', { foo: 'Foo' }), 'Foo') 27 | }) 28 | 29 | test('Select: fall back to default variant', function() { 30 | assert.equal(rt.select('bar', 'foo', { foo: 'Foo' }), 'Foo') 31 | }) 32 | 33 | test('Select with number', function() { 34 | assert.equal(rt.select(1, 'other', { one: 'Foo', other: 'Bar' }), 'Foo') 35 | }) 36 | 37 | test('Select with NUMBER()', function() { 38 | const n = rt.NUMBER({}, 1) 39 | assert.equal(rt.select(n, 'other', { one: 'Foo', other: 'Bar' }), 'Foo') 40 | }) 41 | }) 42 | 43 | suite('With set locale "fi"', function() { 44 | let rt 45 | setup(function() { 46 | rt = new Runtime('fi') 47 | }) 48 | 49 | test('Built-in DATETIME formatter', function() { 50 | const dtf = new Intl.DateTimeFormat('fi') 51 | const d = new Date() 52 | assert.equal(rt.DATETIME({}, d), dtf.format(d)) 53 | }) 54 | 55 | test('Built-in NUMBER formatter', function() { 56 | const nf = new Intl.NumberFormat('fi') 57 | const value = 1.2 58 | const fmt = nf.format(value) 59 | assert.deepEqual(rt.NUMBER({}, value), { $: {}, fmt, value }) 60 | }) 61 | 62 | test('Select with number', function() { 63 | assert.equal(rt.select(1, 'other', { one: 'Foo', other: 'Bar' }), 'Foo') 64 | }) 65 | 66 | test('Select with NUMBER()', function() { 67 | const n = rt.NUMBER({}, 1) 68 | assert.equal(rt.select(n, 'other', { one: 'Foo', other: 'Bar' }), 'Foo') 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /packages/loader/test/loader.test.js: -------------------------------------------------------------------------------- 1 | import { source } from 'common-tags' 2 | import compiler from './es6-compiler' 3 | 4 | test('Simple FTL', async () => { 5 | const js = await compiler('fixtures/simple.ftl') 6 | expect(js).toBe(source` 7 | import { FluentBundle } from "fluent-runtime/bundle"; 8 | import { Runtime } from "fluent-runtime"; 9 | const { isol } = Runtime(["en-US"]); 10 | const R = new Map([ 11 | 12 | ["hello-user", { value: $ => ["Hello, ", isol($.userName), "!"] }], 13 | 14 | ]); 15 | export default new FluentBundle(["en-US"], R); 16 | `) 17 | }) 18 | 19 | test('Complex FTL with options', async () => { 20 | const js = await compiler('fixtures/complex.ftl', { useIsolating: false }) 21 | expect(js).toBe(source` 22 | import { FluentBundle } from "fluent-runtime/bundle"; 23 | import { Runtime } from "fluent-runtime"; 24 | const { select } = Runtime(["en-US"]); 25 | const R = new Map([ 26 | 27 | // Simple things are simple. 28 | ["hello-user", { value: $ => ["Hello, ", $.userName, "!"] }], 29 | 30 | // Complex things are possible. 31 | ["shared-photos", { value: $ => [$.userName, " ", select($.photoCount, "other", { one: "added a new photo", other: ["added ", $.photoCount, " new photos"] }), " to ", select($.userGender, "other", { male: "his stream", female: "her stream", other: "their stream" }), "."] }], 32 | 33 | ]); 34 | export default new FluentBundle(["en-US"], R); 35 | `) 36 | }) 37 | 38 | describe('Options', () => { 39 | test('locales: ["en-ZA"], useIsolating: false', async () => { 40 | const js = await compiler('fixtures/simple.ftl', { 41 | locales: ['en-ZA'], 42 | useIsolating: false 43 | }) 44 | expect(js).toBe(source` 45 | import { FluentBundle } from "fluent-runtime/bundle"; 46 | const R = new Map([ 47 | 48 | ["hello-user", { value: $ => ["Hello, ", $.userName, "!"] }], 49 | 50 | ]); 51 | export default new FluentBundle(["en-ZA"], R); 52 | `) 53 | }) 54 | 55 | test('locales: no match', async () => { 56 | const js = await compiler('fixtures/simple.ftl', { 57 | locales: ['foo', 'bar'] 58 | }) 59 | expect(js).toMatch(`const { isol } = Runtime(["foo"])`) 60 | }) 61 | 62 | test('locales: filename match', async () => { 63 | const js = await compiler('fixtures/simple.ftl', { 64 | locales: ['foo', 'simple'] 65 | }) 66 | expect(js).toMatch(`const { isol } = Runtime(["simple"])`) 67 | }) 68 | 69 | test('locales: empty', async () => { 70 | try { 71 | await compiler('fixtures/simple.ftl', { locales: [] }) 72 | throw new Error('Expected compiler call to fail!') 73 | } catch (err) { 74 | expect(err.message).toMatch('If set, `locales` must be a non-empty array') 75 | } 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /test/context.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import ftl from '@fluent/dedent' 3 | 4 | import { compileAndRequire } from './compile-and-require' 5 | 6 | suite('Bundle', function() { 7 | let bundle 8 | 9 | suite('addResource', function() { 10 | suiteSetup(async function() { 11 | bundle = await compileAndRequire('en-US', '') 12 | const src = ftl` 13 | foo = Foo 14 | -bar = Bar 15 | ` 16 | const resource = await compileAndRequire('en-US', src, true) 17 | bundle.addResource(resource) 18 | }) 19 | 20 | test('adds messages', function() { 21 | assert.equal(bundle.hasMessage('foo'), true) 22 | assert.equal(bundle.hasMessage('-bar'), false) 23 | }) 24 | }) 25 | 26 | suite('allowOverrides', function() { 27 | suiteSetup(async function() { 28 | bundle = await compileAndRequire('en-US', 'key = Foo') 29 | }) 30 | 31 | test('addResource allowOverrides is false', async function() { 32 | const resource = await compileAndRequire('en-US', 'key = Bar', true) 33 | let errors = bundle.addResource(resource) 34 | assert.equal(errors.length, 1) 35 | const msg = bundle.getMessage('key') 36 | assert.equal(bundle.formatPattern(msg.value), 'Foo') 37 | }) 38 | 39 | test('addResource allowOverrides is true', async function() { 40 | const resource = await compileAndRequire('en-US', 'key = Bar', true) 41 | let errors = bundle.addResource(resource, { allowOverrides: true }) 42 | assert.equal(errors.length, 0) 43 | const msg = bundle.getMessage('key') 44 | assert.equal(bundle.formatPattern(msg.value), 'Bar') 45 | }) 46 | }) 47 | 48 | suite('hasMessage', function() { 49 | suiteSetup(async function() { 50 | bundle = await compileAndRequire( 51 | 'en-US', 52 | ftl` 53 | foo = Foo 54 | bar = 55 | .attr = Bar Attr 56 | -term = Term 57 | 58 | # ERROR No value. 59 | err1 = 60 | # ERROR Broken value. 61 | err2 = {} 62 | # ERROR No attribute value. 63 | err3 = 64 | .attr = 65 | # ERROR Broken attribute value. 66 | err4 = 67 | .attr1 = Attr 68 | .attr2 = {} 69 | ` 70 | ) 71 | }) 72 | 73 | test('returns true only for public messages', function() { 74 | assert.equal(bundle.hasMessage('foo'), true) 75 | }) 76 | 77 | test('returns false for terms and missing messages', function() { 78 | assert.equal(bundle.hasMessage('-term'), false) 79 | assert.equal(bundle.hasMessage('missing'), false) 80 | assert.equal(bundle.hasMessage('-missing'), false) 81 | }) 82 | 83 | test('returns false for broken messages', function() { 84 | assert.equal(bundle.hasMessage('err1'), false) 85 | assert.equal(bundle.hasMessage('err2'), false) 86 | assert.equal(bundle.hasMessage('err3'), false) 87 | assert.equal(bundle.hasMessage('err4'), false) 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /test/isolating.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import ftl from '@fluent/dedent' 3 | 4 | import { compileAndRequire } from './compile-and-require' 5 | 6 | // Unicode bidi isolation characters. 7 | const FSI = '\u2068' 8 | const PDI = '\u2069' 9 | 10 | suite('Isolating interpolations', function() { 11 | let bundle, args, errs 12 | 13 | suiteSetup(async function() { 14 | bundle = await compileAndRequire( 15 | 'en-US', 16 | ftl` 17 | foo = Foo 18 | bar = { foo } Bar 19 | baz = { $arg } Baz 20 | qux = { bar } { baz } 21 | ` 22 | ) 23 | }) 24 | 25 | setup(function() { 26 | errs = [] 27 | }) 28 | 29 | test('isolates interpolated message references', function() { 30 | const msg = bundle.getMessage('bar') 31 | const val = bundle.formatPattern(msg.value, args, errs) 32 | assert.equal(val, `${FSI}Foo${PDI} Bar`) 33 | assert.equal(errs.length, 0) 34 | }) 35 | 36 | test('isolates interpolated string-typed variables', function() { 37 | const msg = bundle.getMessage('baz') 38 | const val = bundle.formatPattern(msg.value, { arg: 'Arg' }, errs) 39 | assert.equal(val, `${FSI}Arg${PDI} Baz`) 40 | assert.equal(errs.length, 0) 41 | }) 42 | 43 | test('isolates interpolated number-typed variables', function() { 44 | const msg = bundle.getMessage('baz') 45 | const val = bundle.formatPattern(msg.value, { arg: 1 }, errs) 46 | assert.equal(val, `${FSI}1${PDI} Baz`) 47 | assert.equal(errs.length, 0) 48 | }) 49 | 50 | test('isolates interpolated date-typed variables', function() { 51 | const dtf = new Intl.DateTimeFormat('en-US') 52 | const arg = new Date('2016-09-29') 53 | 54 | const msg = bundle.getMessage('baz') 55 | const val = bundle.formatPattern(msg.value, { arg }, errs) 56 | // format the date argument to account for the testrunner's timezone 57 | assert.equal(val, `${FSI}${dtf.format(arg)}${PDI} Baz`) 58 | assert.equal(errs.length, 0) 59 | }) 60 | 61 | test('isolates complex interpolations', function() { 62 | const msg = bundle.getMessage('qux') 63 | const val = bundle.formatPattern(msg.value, { arg: 'Arg' }, errs) 64 | 65 | const expected_bar = `${FSI}${FSI}Foo${PDI} Bar${PDI}` 66 | const expected_baz = `${FSI}${FSI}Arg${PDI} Baz${PDI}` 67 | assert.equal(val, `${expected_bar} ${expected_baz}`) 68 | assert.equal(errs.length, 0) 69 | }) 70 | }) 71 | 72 | suite('Skip isolation cases', function() { 73 | let bundle, args, errs 74 | 75 | suiteSetup(async function() { 76 | bundle = await compileAndRequire( 77 | 'en-US', 78 | ftl` 79 | -brand-short-name = Amaya 80 | foo = { -brand-short-name } 81 | ` 82 | ) 83 | }) 84 | 85 | setup(function() { 86 | errs = [] 87 | }) 88 | 89 | test('skips isolation if the only element is a placeable', function() { 90 | const msg = bundle.getMessage('foo') 91 | const val = bundle.formatPattern(msg.value, args, errs) 92 | assert.equal(val, `Amaya`) 93 | assert.equal(errs.length, 0) 94 | }) 95 | }) 96 | -------------------------------------------------------------------------------- /test/functions_builtin.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import ftl from '@fluent/dedent' 3 | 4 | import { compileAndRequire } from './compile-and-require' 5 | 6 | suite('Built-in functions', function() { 7 | suite('NUMBER', function() { 8 | let bundle, msgDec, msgPct, msgBad 9 | 10 | suiteSetup(async () => { 11 | bundle = await compileAndRequire( 12 | 'en-US', 13 | ftl` 14 | num-decimal = { NUMBER($arg) } 15 | num-percent = { NUMBER($arg, style: "percent") } 16 | num-bad-opt = { NUMBER($arg, style: "bad") } 17 | ` 18 | ) 19 | msgDec = bundle.getMessage('num-decimal') 20 | msgPct = bundle.getMessage('num-percent') 21 | msgBad = bundle.getMessage('num-bad-opt') 22 | }) 23 | 24 | test('missing argument', function() { 25 | assert.equal(bundle.formatPattern(msgDec.value), 'NaN') 26 | assert.equal(bundle.formatPattern(msgPct.value), 'NaN') 27 | assert.equal(bundle.formatPattern(msgBad.value), 'NaN') 28 | }) 29 | 30 | test('number argument', function() { 31 | const args = { arg: 1 } 32 | assert.equal(bundle.formatPattern(msgDec.value, args), '1') 33 | assert.equal(bundle.formatPattern(msgPct.value, args), '100%') 34 | assert.equal(bundle.formatPattern(msgBad.value, args), '1') 35 | }) 36 | 37 | test('string argument', function() { 38 | const args = { arg: 'Foo' } 39 | assert.equal(bundle.formatPattern(msgDec.value, args), 'NaN') 40 | assert.equal(bundle.formatPattern(msgPct.value, args), 'NaN') 41 | assert.equal(bundle.formatPattern(msgBad.value, args), 'NaN') 42 | }) 43 | }) 44 | 45 | suite('DATETIME', function() { 46 | let bundle, msgDef, msgMon, msgBad 47 | 48 | suiteSetup(async () => { 49 | bundle = await compileAndRequire( 50 | 'en-US', 51 | ftl` 52 | dt-default = { DATETIME($arg) } 53 | dt-month = { DATETIME($arg, month: "long") } 54 | dt-bad-opt = { DATETIME($arg, month: "bad") } 55 | ` 56 | ) 57 | msgDef = bundle.getMessage('dt-default') 58 | msgMon = bundle.getMessage('dt-month') 59 | msgBad = bundle.getMessage('dt-bad-opt') 60 | }) 61 | 62 | test('missing argument', function() { 63 | assert.equal(bundle.formatPattern(msgDef.value), 'Invalid Date') 64 | assert.equal(bundle.formatPattern(msgMon.value), 'Invalid Date') 65 | assert.equal(bundle.formatPattern(msgBad.value), 'Invalid Date') 66 | }) 67 | 68 | test('Date argument', function() { 69 | const date = new Date('2016-09-29') 70 | // format the date argument to account for the testrunner's timezone 71 | const expectedDefault = new Intl.DateTimeFormat('en-US').format(date) 72 | const expectedMonth = new Intl.DateTimeFormat('en-US', { 73 | month: 'long' 74 | }).format(date) 75 | 76 | const args = { arg: date } 77 | assert.equal(bundle.formatPattern(msgDef.value, args), expectedDefault) 78 | assert.equal(bundle.formatPattern(msgMon.value, args), expectedMonth) 79 | 80 | // The argument value will be coerced into a string by the join operation 81 | // in FluentBundle.format. The result looks something like this; it 82 | // may vary depending on the TZ: 83 | // Thu Sep 29 2016 02:00:00 GMT+0200 (CEST) 84 | 85 | // Skipping for now, as it's not clear why behaviour differs --Eemeli 86 | // assert.equal(bundle.format('dt-bad-opt', args), date.toString()) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /packages/loader/README.md: -------------------------------------------------------------------------------- 1 | # fluent-loader 2 | 3 | [Webpack] loader for [Fluent], using [fluent-compiler]. Allows FTL files to be loaded as Fluent [bundles] or [resources]. 4 | 5 | [webpack]: https://webpack.js.org/ 6 | [fluent]: https://projectfluent.org/ 7 | [fluent-compiler]: https://www.npmjs.com/package/fluent-compiler 8 | [bundles]: http://projectfluent.org/fluent.js/fluent/class/src/bundle.js~FluentBundle.html 9 | [resources]: http://projectfluent.org/fluent.js/fluent/class/src/resource.js~FluentResource.html 10 | 11 | ## Installation 12 | 13 | ``` 14 | npm install --save-dev fluent-loader 15 | npm install --save-dev babel-loader @babel/core @babel/preset-env @babel/plugin-proposal-object-rest-spread 16 | ``` 17 | 18 | The output of `fluent-loader` is an ES6 module that may use `...spread` notation and other modern ES features, so it'll probably need to transpiled for your target environment; hence the second set of suggested dev dependencies above. 19 | 20 | ## Configuration 21 | 22 | #### Simple 23 | 24 | ```js 25 | module.exports = { 26 | module: { 27 | rules: [ 28 | { 29 | test: /\.ftl$/, 30 | use: ['babel-loader', 'fluent-loader'] 31 | } 32 | ] 33 | } 34 | } 35 | ``` 36 | 37 | #### With Options 38 | 39 | ```js 40 | module.exports = { 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ftl$/, 45 | use: [ 46 | { 47 | loader: 'babel-loader', 48 | options: { 49 | presets: ['@babel/preset-env'], 50 | plugins: ['@babel/plugin-proposal-object-rest-spread'] 51 | } 52 | }, 53 | { 54 | loader: 'fluent-loader', 55 | options: { 56 | locales: ['en-US'], 57 | useIsolating: true // Wrap placeables with Unicode FSI & PDI isolation marks 58 | } 59 | } 60 | ] 61 | } 62 | ] 63 | } 64 | } 65 | ``` 66 | 67 | All `fluent-loader` options are optional, and the values shown above are the defaults. All other [fluent-compiler] options are also supported. 68 | 69 | If `locales` contains more than one entry, the locale of an FTL file will be detected by looking for a matching substring in the file path. For example, if `locales` is `['en-US', 'fi-FI']`, files such as `messages_fi.ftl` and `i18n/fi-FI/messages.ftl` will be recognised as having a Finnish locale. These substrings must be separated from the rest of the path by `/`, `.` or `_` characters. On no match, the first entry of `locales` will be used as the default value. 70 | 71 | ## Usage 72 | 73 | Presuming configuration with the options `{ locales: ['en', 'fi'], useIsolating: false }`: 74 | 75 | ### `messages.ftl` 76 | 77 | ``` 78 | hello-user = Hello, {$userName}! 79 | shared-photos = 80 | {$userName} {$photoCount -> 81 | [one] added a new photo 82 | *[other] added {$photoCount} new photos 83 | } to {$userGender -> 84 | [male] his album 85 | [female] her album 86 | *[other] their album 87 | }. 88 | ``` 89 | 90 | ### `messages.fi.ftl` 91 | 92 | ``` 93 | hello-user = Hei, {$userName}! 94 | shared-photos = 95 | {$userName} lisäsi {$photoCount -> 96 | [one] uuden kuvan 97 | *[other] {$photoCount} uutta kuvaa 98 | } albumiinsa. 99 | ``` 100 | 101 | ### `app.js` 102 | 103 | ```js 104 | import en from './messages.ftl' 105 | import fi from './messages.fi.ftl' 106 | 107 | en.locales // ['en'] 108 | fi.locales // ['fi'] 109 | 110 | const userData = { 111 | photoCount: 1, 112 | userGender: 'female', 113 | userName: 'Eve' 114 | } 115 | 116 | en.format('hello-user', userData) // 'Hello, Eve!' 117 | fi.format('hello-user', userData) // 'Hei, Eve!' 118 | 119 | en.format('shared-photos', userData) // 'Eve added a new photo to her album.' 120 | fi.format('shared-photos', userData) // 'Eve lisäsi uuden kuvan albumiinsa.' 121 | ``` 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fluent-compiler 2 | 3 | `fluent-compiler` provides a JavaScript stringifier for [Fluent]. Essentially, 4 | it's a transpiler that allows converting files from Fluent's `ftl` format to 5 | JavaScript, outputting an ES6 module that exports a [FluentBundle][bundle]. 6 | 7 | The difference between this package and the core `fluent` package is that the 8 | latter will need to compile your messages on the client, and is about 10kB when 9 | compressed. The runtime component of `fluent-compiler` is about 1.2kB, and it 10 | lets you take care of the message compilation during your build. 11 | 12 | **NOTE:** The current runtime implements the `format()/compound()` API of 13 | Fluent.js [PR #360], which is likely still to get revised. 14 | 15 | [fluent]: https://projectfluent.org/ 16 | [bundle]: http://projectfluent.org/fluent.js/fluent/class/src/bundle.js~FluentBundle.html 17 | [resource]: http://projectfluent.org/fluent.js/fluent/class/src/resource.js~FluentResource.html 18 | [pr #360]: https://github.com/projectfluent/fluent.js/pull/360 19 | 20 | ## API 21 | 22 | ```js 23 | import { compile } from 'fluent-compiler' 24 | ``` 25 | 26 | ### `compile(locales, source, options = {}) => string` 27 | 28 | | Param | Type | Description | 29 | | ------- | ------------------------------- | ---------------------------------------------------------------------------------- | 30 | | locales | `string | string[] | undefined` | The resource's locale identifier | 31 | | source | `string | Resource` | Fluent source as a string, or an AST compiled with the [`fluent-syntax`][1] parser | 32 | | options | `CompilerOptions` | Compiler options object (optional) | 33 | 34 | #### `CompilerOptions` 35 | 36 | | Option | Type | Default | Description | 37 | | -------------- | ---------- | --------------------------- | ------------------------------------------------------------- | 38 | | runtime | `string` | `'bundle'` | The type of runtime to use; either `'bundle'` or `'resource'` | 39 | | runtimeGlobals | `string[]` | `['DATETIME', 'NUMBER']` | Identifiers of global functions available in the runtime | 40 | | runtimePath | `string` | `'fluent-compiler/runtime'` | Path for the runtime dependency | 41 | | useIsolating | `boolean` | `true` | Wrap placeables with Unicode FSI & PDI isolation marks | 42 | | withJunk | `boolean` | `false` | Include unparsed source as comments in the output | 43 | 44 | [1]: https://www.npmjs.com/package/fluent-syntax 45 | 46 | The string returned by `compile()` is the string representation of an ES6 module, which in turn exports either the [bundle] or [resource] interfaces for the source messages. Note that the `bundle.addMessages()` is not included, as it requires message compilation; use `bundle.addResource()` instead: 47 | 48 | ```js 49 | import bundle from './default_messages' 50 | import { resource } from './extra_messages' 51 | 52 | bundle.addResource(resource, { allowOverrides: true }) 53 | // bundle now includes all default_messages as well as extra_messages, 54 | // with the latter overriding the former 55 | ``` 56 | 57 | ## Usage 58 | 59 | Fluent source file `messages.it.ftl`: 60 | 61 | ```ftl 62 | -sync-brand-name = {$capitalization -> 63 | *[uppercase] Account Firefox 64 | [lowercase] account Firefox 65 | } 66 | 67 | sync-dialog-title = {-sync-brand-name} 68 | sync-headline-title = 69 | {-sync-brand-name}: il modo migliore 70 | per avere i tuoi dati sempre con te 71 | 72 | # Explicitly request the lowercase variant of the brand name. 73 | sync-signedout-account-title = 74 | Connetti il tuo {-sync-brand-name(capitalization: "lowercase")} 75 | ``` 76 | 77 | Build script: 78 | 79 | ```js 80 | import { compile } from 'fluent-compiler' 81 | import fs from 'fs' 82 | 83 | const src = fs.readFileSync('messages.it.ftl') 84 | const js = compile('it', src) 85 | fs.writeFileSync('messages.it.js', js) 86 | ``` 87 | 88 | Application code: 89 | 90 | ```js 91 | import it from './messages.it' 92 | 93 | it.format('sync-signedout-account-title') 94 | // 'Connetti il tuo account Firefox' 95 | ``` 96 | 97 | ## Polyfills 98 | 99 | The ES6 module output by `compile()` will probably need to itself be transpiled, 100 | as it uses Object Spread syntax (currently at Stage 3). Furthermore, the runtime 101 | may need polyfills for the Intl objects and Object.entries (used by the bundle's 102 | `messages` getter). In particular, [intl-pluralrules] patches some of the 103 | deficiencies in current browsers. 104 | 105 | [intl-pluralrules]: https://www.npmjs.com/package/intl-pluralrules 106 | -------------------------------------------------------------------------------- /test/select_expressions.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import ftl from '@fluent/dedent' 3 | 4 | import { compileAndRequire } from './compile-and-require' 5 | 6 | suite('Select expressions', function() { 7 | let errs 8 | setup(function() { 9 | errs = [] 10 | }) 11 | 12 | test('missing selector', async function() { 13 | const bundle = await compileAndRequire( 14 | 'en-US', 15 | ftl` 16 | select = {$none -> 17 | [a] A 18 | *[b] B 19 | } 20 | ` 21 | ) 22 | const msg = bundle.getMessage('select') 23 | const val = bundle.formatPattern(msg.value, null, errs) 24 | assert.equal(val, 'B') 25 | 26 | // Skipping as the missing variable is not detected; instead treated as 27 | // `undefined`. --Eemeli 28 | // assert.equal(errs.length, 1) 29 | // assert(errs[0] instanceof ReferenceError) // unknown variable 30 | }) 31 | 32 | suite('string selectors', function() { 33 | test('matching selector', async function() { 34 | const bundle = await compileAndRequire( 35 | 'en-US', 36 | ftl` 37 | select = {$selector -> 38 | [a] A 39 | *[b] B 40 | } 41 | ` 42 | ) 43 | const msg = bundle.getMessage('select') 44 | const val = bundle.formatPattern(msg.value, { selector: 'a' }, errs) 45 | assert.equal(val, 'A') 46 | assert.equal(errs.length, 0) 47 | }) 48 | 49 | test('non-matching selector', async function() { 50 | const bundle = await compileAndRequire( 51 | 'en-US', 52 | ftl` 53 | select = {$selector -> 54 | [a] A 55 | *[b] B 56 | } 57 | ` 58 | ) 59 | const msg = bundle.getMessage('select') 60 | const val = bundle.formatPattern(msg.value, { selector: 'c' }, errs) 61 | assert.equal(val, 'B') 62 | assert.equal(errs.length, 0) 63 | }) 64 | }) 65 | 66 | suite('number selectors', function() { 67 | test('matching selector', async function() { 68 | const bundle = await compileAndRequire( 69 | 'en-US', 70 | ftl` 71 | select = {$selector -> 72 | [0] A 73 | *[1] B 74 | } 75 | ` 76 | ) 77 | const msg = bundle.getMessage('select') 78 | const val = bundle.formatPattern(msg.value, { selector: 0 }, errs) 79 | assert.equal(val, 'A') 80 | assert.equal(errs.length, 0) 81 | }) 82 | 83 | test('non-matching selector', async function() { 84 | const bundle = await compileAndRequire( 85 | 'en-US', 86 | ftl` 87 | select = {$selector -> 88 | [0] A 89 | *[1] B 90 | } 91 | ` 92 | ) 93 | const msg = bundle.getMessage('select') 94 | const val = bundle.formatPattern(msg.value, { selector: 2 }, errs) 95 | assert.equal(val, 'B') 96 | assert.equal(errs.length, 0) 97 | }) 98 | }) 99 | 100 | suite('plural categories', function() { 101 | test('matching number selector', async function() { 102 | const bundle = await compileAndRequire( 103 | 'en-US', 104 | ftl` 105 | select = {$selector -> 106 | [one] A 107 | *[other] B 108 | } 109 | ` 110 | ) 111 | const msg = bundle.getMessage('select') 112 | const val = bundle.formatPattern(msg.value, { selector: 1 }, errs) 113 | assert.equal(val, 'A') 114 | assert.equal(errs.length, 0) 115 | }) 116 | 117 | test('matching string selector', async function() { 118 | const bundle = await compileAndRequire( 119 | 'en-US', 120 | ftl` 121 | select = {$selector -> 122 | [one] A 123 | *[other] B 124 | } 125 | ` 126 | ) 127 | const msg = bundle.getMessage('select') 128 | const val = bundle.formatPattern(msg.value, { selector: 'one' }, errs) 129 | assert.equal(val, 'A') 130 | assert.equal(errs.length, 0) 131 | }) 132 | 133 | test('non-matching number selector', async function() { 134 | const bundle = await compileAndRequire( 135 | 'en-US', 136 | ftl` 137 | select = {$selector -> 138 | [one] A 139 | *[default] D 140 | } 141 | ` 142 | ) 143 | const msg = bundle.getMessage('select') 144 | const val = bundle.formatPattern(msg.value, { selector: 2 }, errs) 145 | assert.equal(val, 'D') 146 | assert.equal(errs.length, 0) 147 | }) 148 | 149 | test('non-matching string selector', async function() { 150 | const bundle = await compileAndRequire( 151 | 'en-US', 152 | ftl` 153 | select = {$selector -> 154 | [one] A 155 | *[default] D 156 | } 157 | ` 158 | ) 159 | const msg = bundle.getMessage('select') 160 | const val = bundle.formatPattern(msg.value, { selector: 'other' }, errs) 161 | assert.equal(val, 'D') 162 | assert.equal(errs.length, 0) 163 | }) 164 | 165 | test('NUMBER() selector', async function() { 166 | const bundle = await compileAndRequire( 167 | 'en-US', 168 | ftl` 169 | select = {NUMBER($selector) -> 170 | [one] A 171 | *[other] B 172 | } 173 | ` 174 | ) 175 | const msg = bundle.getMessage('select') 176 | const val = bundle.formatPattern(msg.value, { selector: 1 }, errs) 177 | assert.equal(val, 'A') 178 | assert.equal(errs.length, 0) 179 | }) 180 | 181 | test('NUMBER() selector with type: "ordinal"', async function() { 182 | const bundle = await compileAndRequire( 183 | 'en-US', 184 | ftl` 185 | select = {NUMBER($selector, type: "ordinal") -> 186 | [two] A 187 | *[other] B 188 | } 189 | ` 190 | ) 191 | const msg = bundle.getMessage('select') 192 | const val = bundle.formatPattern(msg.value, { selector: 2 }, errs) 193 | assert.equal(val, 'A') 194 | assert.equal(errs.length, 0) 195 | }) 196 | 197 | test('NUMBER() selector with type: "ordinal" and exact case', async function() { 198 | const bundle = await compileAndRequire( 199 | 'en-US', 200 | ftl` 201 | select = {NUMBER($selector, type: "ordinal") -> 202 | [2] A 203 | [two] B 204 | *[other] C 205 | } 206 | ` 207 | ) 208 | const msg = bundle.getMessage('select') 209 | const val = bundle.formatPattern(msg.value, { selector: 2 }, errs) 210 | assert.equal(val, 'A') 211 | assert.equal(errs.length, 0) 212 | }) 213 | }) 214 | }) 215 | -------------------------------------------------------------------------------- /packages/compiler/src/compiler.js: -------------------------------------------------------------------------------- 1 | import { property } from 'safe-identifier' 2 | 3 | export class FluentCompiler { 4 | constructor({ 5 | runtime = 'bundle', 6 | runtimeGlobals = ['DATETIME', 'NUMBER'], 7 | runtimePath = 'fluent-runtime', 8 | useIsolating = true, 9 | withJunk = false 10 | } = {}) { 11 | this.runtime = runtime 12 | this.runtimeGlobals = runtimeGlobals 13 | this.runtimePath = runtimePath 14 | this.useIsolating = useIsolating 15 | this.withJunk = withJunk 16 | this._rtImports = {} 17 | } 18 | 19 | compile(locales, resource) { 20 | if (resource.type !== 'Resource') { 21 | throw new Error(`Unknown resource type: ${resource.type}`) 22 | } 23 | 24 | const lc = locales 25 | ? JSON.stringify(Array.isArray(locales) ? locales : [locales]) 26 | : 'undefined' 27 | this._rtImports = { isol: false, select: false } 28 | for (const fn of this.runtimeGlobals) this._rtImports[fn] = false 29 | 30 | const head = ['const R = new Map(['] 31 | const body = resource.body 32 | .filter(entry => entry.type !== 'Junk' || this.withJunk) 33 | .map(entry => this.entry(entry)) // may modify this._rtImports 34 | const foot = [']);'] 35 | 36 | const rt = Object.keys(this._rtImports).filter(key => this._rtImports[key]) 37 | if (rt.length > 0) { 38 | head.unshift( 39 | `import { Runtime } from "${this.runtimePath}";`, 40 | `const { ${rt.join(', ')} } = Runtime(${lc});` 41 | ) 42 | } 43 | 44 | switch (this.runtime) { 45 | case 'bundle': 46 | head.unshift( 47 | `import { FluentBundle } from "${this.runtimePath}/bundle";` 48 | ) 49 | foot.push(`export default new FluentBundle(${lc}, R);`) 50 | break 51 | case 'resource': 52 | foot.push('export default R;') 53 | if (locales) foot.push(`export const locales = ${lc};`) 54 | break 55 | default: 56 | throw new Error(`Unknown runtime ${JSON.stringify(this.runtime)}`) 57 | } 58 | return `${head.join('\n')}\n\n${body.join('\n').trim()}\n\n${foot.join( 59 | '\n' 60 | )}\n` 61 | } 62 | 63 | entry(entry) { 64 | switch (entry.type) { 65 | case 'Message': 66 | return this.message(entry) 67 | case 'Term': 68 | return this.term(entry) 69 | case 'Comment': 70 | return this.comment(entry, '//') 71 | case 'GroupComment': 72 | return this.comment(entry, '// ##') 73 | case 'ResourceComment': 74 | return this.comment(entry, '// ###') 75 | case 'Junk': 76 | return this.junk(entry) 77 | default: 78 | throw new Error(`Unknown entry type: ${entry.type}`) 79 | } 80 | } 81 | 82 | comment(comment, prefix = '//') { 83 | const cc = comment.content 84 | .split('\n') 85 | .map(line => (line.length ? `${prefix} ${line}` : prefix)) 86 | .join('\n') 87 | return `\n${cc}\n` 88 | } 89 | 90 | junk(junk) { 91 | return junk.content.replace(/^/gm, '// ') 92 | } 93 | 94 | message(message) { 95 | const head = message.comment ? this.comment(message.comment) : '' 96 | const name = JSON.stringify(message.id.name) 97 | const value = message.value ? this.pattern(message.value, false) : ' null' 98 | const body = this.messageBody(name, value, message.attributes) 99 | return head + body 100 | } 101 | 102 | term(term) { 103 | const head = term.comment ? this.comment(term.comment) : '' 104 | const name = JSON.stringify(`-${term.id.name}`) 105 | const value = this.pattern(term.value, false) 106 | const body = this.messageBody(name, value, term.attributes) 107 | return head + body 108 | } 109 | 110 | messageBody(name, value, attributes) { 111 | const attr = [] 112 | for (const attribute of attributes) { 113 | const name = JSON.stringify(attribute.id.name) 114 | const value = this.pattern(attribute.value, false) 115 | attr.push(`${name}: $ =>${value}`) 116 | } 117 | switch (attr.length) { 118 | case 0: 119 | return `[${name}, { value: $ =>${value} }],` 120 | case 1: 121 | return [ 122 | `[${name}, {`, 123 | ` value: $ =>${value},`, 124 | ` attributes: { ${attr[0]} }`, 125 | '}],' 126 | ].join('\n') 127 | default: 128 | return [ 129 | `[${name}, {`, 130 | ` value: $ =>${value},`, 131 | ' attributes: {', 132 | ` ${attr.join(',\n ')}`, 133 | ' }', 134 | '}],' 135 | ].join('\n') 136 | } 137 | } 138 | 139 | pattern(pattern, inVariant) { 140 | const singleElement = pattern.elements.length === 1 141 | const useIsolating = this.useIsolating && (inVariant || !singleElement) 142 | const content = [] 143 | for (const el of pattern.elements) { 144 | content.push(this.element(el, useIsolating)) 145 | } 146 | if (inVariant && singleElement) { 147 | return ` ${content[0]}` 148 | } 149 | return ` [${content.join(', ')}]` 150 | } 151 | 152 | element(element, useIsolating) { 153 | switch (element.type) { 154 | case 'TextElement': 155 | return JSON.stringify(element.value) 156 | case 'Placeable': { 157 | const expr = this.expression(element.expression) 158 | if (useIsolating) { 159 | this._rtImports.isol = true 160 | return `isol(${expr})` 161 | } 162 | return expr 163 | } 164 | default: 165 | throw new Error(`Unknown element type: ${element.type}`) 166 | } 167 | } 168 | 169 | expression(expr) { 170 | switch (expr.type) { 171 | case 'StringLiteral': 172 | return `"${expr.value}"` 173 | case 'NumberLiteral': 174 | return expr.value 175 | case 'VariableReference': 176 | return property('$', expr.id.name) 177 | case 'TermReference': { 178 | let out = `R.get(${JSON.stringify(`-${expr.id.name}`)})` 179 | if (expr.attribute) { 180 | out = property(`${out}.attributes`, expr.attribute.name) 181 | } else { 182 | out = `${out}.value` 183 | } 184 | const args = this.termArguments(expr.arguments) 185 | return `${out}${args}` 186 | } 187 | case 'MessageReference': { 188 | let out = `R.get(${JSON.stringify(expr.id.name)})` 189 | if (expr.attribute) { 190 | out = property(`${out}.attributes`, expr.attribute.name) 191 | } else { 192 | out = `${out}.value` 193 | } 194 | return `${out}($)` 195 | } 196 | case 'FunctionReference': { 197 | const fnName = expr.id.name 198 | if (!this.runtimeGlobals.includes(fnName)) 199 | throw new Error(`Unsupported global ${fnName}`) 200 | this._rtImports[fnName] = true 201 | const args = this.functionArguments(expr.arguments) 202 | return `${fnName}${args}` 203 | } 204 | case 'SelectExpression': { 205 | const selector = this.expression(expr.selector) 206 | const defaultVariant = expr.variants.find(variant => variant.default) 207 | const defaultKey = JSON.stringify(this.variantKey(defaultVariant.key)) 208 | const variants = expr.variants.map(this.variant, this).join(', ') 209 | this._rtImports.select = true 210 | return `select(${selector}, ${defaultKey}, { ${variants} })` 211 | } 212 | case 'Placeable': 213 | return this.expression(expr.expression) 214 | default: 215 | throw new Error(`Unknown expression type: ${expr.type}`) 216 | } 217 | } 218 | 219 | termArguments(expr) { 220 | if (!expr || expr.named.length === 0) return '()' 221 | const named = expr.named.map(this.namedArgument, this) 222 | return `({ ${named.join(', ')} })` 223 | } 224 | 225 | functionArguments(expr) { 226 | let ctx = '$' 227 | if (expr && expr.named.length > 0) { 228 | const named = expr.named.map(this.namedArgument, this) 229 | ctx = `{ ...$, ${named.join(', ')} }` 230 | } 231 | if (expr && expr.positional.length > 0) { 232 | const positional = expr.positional.map(this.expression, this) 233 | return `(${ctx}, ${positional.join(', ')})` 234 | } else { 235 | return `(${ctx})` 236 | } 237 | } 238 | 239 | namedArgument(arg) { 240 | const key = property(null, arg.name.name) 241 | const value = this.expression(arg.value) 242 | return `${key}: ${value}` 243 | } 244 | 245 | variant(variant) { 246 | const key = this.variantKey(variant.key) 247 | const value = this.pattern(variant.value, true) 248 | return `${key}:${value}` 249 | } 250 | 251 | variantKey(key) { 252 | switch (key.type) { 253 | case 'Identifier': 254 | return key.name 255 | case 'NumberLiteral': 256 | return key.value 257 | default: 258 | throw new Error(`Unknown variant key type: ${key.type}`) 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /packages/compiler/src/compiler.test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import ftl from '@fluent/dedent' 3 | import { FluentParser } from 'fluent-syntax' 4 | 5 | import { FluentCompiler } from './compiler' 6 | 7 | function trimModuleHeaders(source) { 8 | const footer = ftl` 9 | 10 | 11 | ]\\); 12 | export default .* 13 | 14 | ` 15 | return source 16 | .replace(/^(import { (FluentBundle|Runtime) } from .*\n)+/, '') 17 | .replace(/^const { .* } = Runtime.*\n/, '') 18 | .replace(/^const R = new Map\(\[\n\n/, '') 19 | .replace(new RegExp(footer + '$'), '') 20 | } 21 | 22 | suite('Compile entry', function() { 23 | setup(function() { 24 | this.compiler = new FluentCompiler() 25 | }) 26 | 27 | test('simple message', function() { 28 | const input = { 29 | comment: null, 30 | value: { 31 | elements: [ 32 | { 33 | type: 'TextElement', 34 | value: 'Foo' 35 | } 36 | ], 37 | type: 'Pattern' 38 | }, 39 | attributes: [], 40 | type: 'Message', 41 | id: { 42 | type: 'Identifier', 43 | name: 'foo' 44 | } 45 | } 46 | const output = '["foo", { value: $ => ["Foo"] }],' 47 | const message = this.compiler.entry(input) 48 | assert.equal(message, output) 49 | }) 50 | }) 51 | 52 | suite('Compile resource', function() { 53 | let pretty 54 | 55 | setup(function() { 56 | const parser = new FluentParser() 57 | const compiler = new FluentCompiler({ 58 | runtimeGlobals: ['FOO'], 59 | useIsolating: false, 60 | withJunk: false 61 | }) 62 | 63 | pretty = function pretty(text) { 64 | const res = parser.parse(text) 65 | return trimModuleHeaders(compiler.compile(undefined, res)) 66 | } 67 | }) 68 | 69 | test('invalid resource', function() { 70 | const compiler = new FluentCompiler() 71 | assert.throws( 72 | () => compiler.compile(undefined, null), 73 | /Cannot read property 'type'/ 74 | ) 75 | assert.throws( 76 | () => compiler.compile(undefined, {}), 77 | /Unknown resource type/ 78 | ) 79 | }) 80 | 81 | test('simple message', function() { 82 | const input = ftl` 83 | foo = Foo 84 | ` 85 | const output = ftl` 86 | ["foo", { value: $ => ["Foo"] }], 87 | ` 88 | assert.equal(pretty(input), output) 89 | }) 90 | 91 | test('simple term', function() { 92 | const input = ftl` 93 | -foo = Foo 94 | ` 95 | const output = ftl` 96 | ["-foo", { value: $ => ["Foo"] }], 97 | ` 98 | assert.equal(pretty(input), output) 99 | }) 100 | 101 | test('two simple messages', function() { 102 | const input = ftl` 103 | foo = Foo 104 | bar = Bar 105 | ` 106 | const output = ftl` 107 | ["foo", { value: $ => ["Foo"] }], 108 | ["bar", { value: $ => ["Bar"] }], 109 | ` 110 | assert.equal(pretty(input), output) 111 | }) 112 | 113 | test('two messages with conflicting JS names', function() { 114 | const input = ftl` 115 | foo-a = Foo 116 | foo_a = Bar 117 | ` 118 | const output = ftl` 119 | ["foo-a", { value: $ => ["Foo"] }], 120 | ["foo_a", { value: $ => ["Bar"] }], 121 | ` 122 | assert.equal(pretty(input), output) 123 | }) 124 | 125 | test('block multiline message', function() { 126 | const input = ftl` 127 | foo = 128 | Foo 129 | Bar 130 | ` 131 | const output = ftl` 132 | ["foo", { value: $ => ["Foo\\nBar"] }], 133 | ` 134 | assert.equal(pretty(input), output) 135 | }) 136 | 137 | test('inline multiline message', function() { 138 | const input = ftl` 139 | foo = Foo 140 | Bar 141 | ` 142 | const output = ftl` 143 | ["foo", { value: $ => ["Foo\\nBar"] }], 144 | ` 145 | assert.equal(pretty(input), output) 146 | }) 147 | 148 | test('message reference', function() { 149 | const input = ftl` 150 | foo = Foo { bar } 151 | ` 152 | const output = ftl` 153 | ["foo", { value: $ => ["Foo ", R.get("bar").value($)] }], 154 | ` 155 | assert.equal(pretty(input), output) 156 | }) 157 | 158 | test('term reference', function() { 159 | const input = ftl` 160 | foo = Foo { -bar } 161 | ` 162 | const output = ftl` 163 | ["foo", { value: $ => ["Foo ", R.get("-bar").value()] }], 164 | ` 165 | assert.equal(pretty(input), output) 166 | }) 167 | 168 | test('external argument', function() { 169 | const input = ftl` 170 | foo = Foo { $bar } 171 | ` 172 | const output = ftl` 173 | ["foo", { value: $ => ["Foo ", $.bar] }], 174 | ` 175 | assert.equal(pretty(input), output) 176 | }) 177 | 178 | test('number element', function() { 179 | const input = ftl` 180 | foo = Foo { 1 } 181 | ` 182 | const output = ftl` 183 | ["foo", { value: $ => ["Foo ", 1] }], 184 | ` 185 | assert.equal(pretty(input), output) 186 | }) 187 | 188 | test('string element', function() { 189 | const input = ftl` 190 | foo = Foo { "bar" } 191 | ` 192 | const output = ftl` 193 | ["foo", { value: $ => ["Foo ", "bar"] }], 194 | ` 195 | assert.equal(pretty(input), output) 196 | }) 197 | 198 | test('attribute expression', function() { 199 | const input = ftl` 200 | foo = Foo { bar.baz } 201 | ` 202 | const output = ftl` 203 | ["foo", { value: $ => ["Foo ", R.get("bar").attributes.baz($)] }], 204 | ` 205 | assert.equal(pretty(input), output) 206 | }) 207 | 208 | test('resource comment', function() { 209 | const input = ftl` 210 | ### A multiline 211 | ### resource comment. 212 | 213 | foo = Foo 214 | ` 215 | const output = ftl` 216 | // ### A multiline 217 | // ### resource comment. 218 | 219 | ["foo", { value: $ => ["Foo"] }], 220 | ` 221 | assert.equal(pretty(input), output) 222 | }) 223 | 224 | test('message comment', function() { 225 | const input = ftl` 226 | # A multiline 227 | # message comment. 228 | foo = Foo 229 | ` 230 | const output = ftl` 231 | // A multiline 232 | // message comment. 233 | ["foo", { value: $ => ["Foo"] }], 234 | ` 235 | assert.equal(pretty(input), output) 236 | }) 237 | 238 | test('group comment', function() { 239 | const input = ftl` 240 | foo = Foo 241 | 242 | ## Comment Header 243 | ## 244 | ## A multiline 245 | ## group comment. 246 | 247 | bar = Bar 248 | ` 249 | const output = ftl` 250 | ["foo", { value: $ => ["Foo"] }], 251 | 252 | // ## Comment Header 253 | // ## 254 | // ## A multiline 255 | // ## group comment. 256 | 257 | ["bar", { value: $ => ["Bar"] }], 258 | ` 259 | assert.equal(pretty(input), output) 260 | }) 261 | 262 | test('standalone comment', function() { 263 | const input = ftl` 264 | foo = Foo 265 | 266 | # A Standalone Comment 267 | 268 | bar = Bar 269 | ` 270 | const output = ftl` 271 | ["foo", { value: $ => ["Foo"] }], 272 | 273 | // A Standalone Comment 274 | 275 | ["bar", { value: $ => ["Bar"] }], 276 | ` 277 | assert.equal(pretty(input), output) 278 | }) 279 | 280 | test('multiline with placeable', function() { 281 | const input = ftl` 282 | foo = 283 | Foo { bar } 284 | Baz 285 | ` 286 | const output = ftl` 287 | ["foo", { value: $ => ["Foo ", R.get("bar").value($), "\\nBaz"] }], 288 | ` 289 | assert.equal(pretty(input), output) 290 | }) 291 | 292 | test('attribute', function() { 293 | const input = ftl` 294 | foo = 295 | .attr = Foo Attr 296 | ` 297 | const output = ftl` 298 | ["foo", { 299 | value: $ => null, 300 | attributes: { "attr": $ => ["Foo Attr"] } 301 | }], 302 | ` 303 | assert.equal(pretty(input), output) 304 | }) 305 | 306 | test('multiline attribute', function() { 307 | const input = ftl` 308 | foo = 309 | .attr = 310 | Foo Attr 311 | Continued 312 | ` 313 | const output = ftl` 314 | ["foo", { 315 | value: $ => null, 316 | attributes: { "attr": $ => ["Foo Attr\\nContinued"] } 317 | }], 318 | ` 319 | assert.equal(pretty(input), output) 320 | }) 321 | 322 | test('two attributes', function() { 323 | const input = ftl` 324 | foo = 325 | .attr-a = Foo Attr A 326 | .attr-b = Foo Attr B 327 | ` 328 | const output = ftl` 329 | ["foo", { 330 | value: $ => null, 331 | attributes: { 332 | "attr-a": $ => ["Foo Attr A"], 333 | "attr-b": $ => ["Foo Attr B"] 334 | } 335 | }], 336 | ` 337 | assert.equal(pretty(input), output) 338 | }) 339 | 340 | test('value and attributes', function() { 341 | const input = ftl` 342 | foo = Foo Value 343 | .attr-a = Foo Attr A 344 | .attr-b = Foo Attr B 345 | ` 346 | const output = ftl` 347 | ["foo", { 348 | value: $ => ["Foo Value"], 349 | attributes: { 350 | "attr-a": $ => ["Foo Attr A"], 351 | "attr-b": $ => ["Foo Attr B"] 352 | } 353 | }], 354 | ` 355 | assert.equal(pretty(input), output) 356 | }) 357 | 358 | test('multiline value and attributes', function() { 359 | const input = ftl` 360 | foo = 361 | Foo Value 362 | Continued 363 | .attr-a = Foo Attr A 364 | .attr-b = Foo Attr B 365 | ` 366 | const output = ftl` 367 | ["foo", { 368 | value: $ => ["Foo Value\\nContinued"], 369 | attributes: { 370 | "attr-a": $ => ["Foo Attr A"], 371 | "attr-b": $ => ["Foo Attr B"] 372 | } 373 | }], 374 | ` 375 | assert.equal(pretty(input), output) 376 | }) 377 | 378 | test('select expression', function() { 379 | const input = ftl` 380 | foo = 381 | { $sel -> 382 | *[a] A 383 | [b] B 384 | } 385 | ` 386 | const output = ftl` 387 | ["foo", { value: $ => [select($.sel, "a", { a: "A", b: "B" })] }], 388 | ` 389 | assert.equal(pretty(input), output) 390 | }) 391 | 392 | test('multiline variant', function() { 393 | const input = ftl` 394 | foo = 395 | { $sel -> 396 | *[a] 397 | AAA 398 | BBB 399 | } 400 | ` 401 | const output = ftl` 402 | ["foo", { value: $ => [select($.sel, "a", { a: "AAA\\nBBB" })] }], 403 | ` 404 | assert.equal(pretty(input), output) 405 | }) 406 | 407 | test('multiline variant with first line inline', function() { 408 | const input = ftl` 409 | foo = 410 | { $sel -> 411 | *[a] AAA 412 | BBB 413 | } 414 | ` 415 | const output = ftl` 416 | ["foo", { value: $ => [select($.sel, "a", { a: "AAA\\nBBB" })] }], 417 | ` 418 | assert.equal(pretty(input), output) 419 | }) 420 | 421 | test('variant key number', function() { 422 | const input = ftl` 423 | foo = 424 | { $sel -> 425 | *[1] 1 426 | } 427 | ` 428 | const output = ftl` 429 | ["foo", { value: $ => [select($.sel, "1", { 1: "1" })] }], 430 | ` 431 | assert.equal(pretty(input), output) 432 | }) 433 | 434 | test('select expression in block value', function() { 435 | const input = ftl` 436 | foo = 437 | Foo { $sel -> 438 | *[a] A 439 | [b] B 440 | } 441 | ` 442 | const output = ftl` 443 | ["foo", { value: $ => ["Foo ", select($.sel, "a", { a: "A", b: "B" })] }], 444 | ` 445 | assert.equal(pretty(input), output) 446 | }) 447 | 448 | test('select expression in inline value', function() { 449 | const input = ftl` 450 | foo = Foo { $sel -> 451 | *[a] A 452 | [b] B 453 | } 454 | ` 455 | const output = ftl` 456 | ["foo", { value: $ => ["Foo ", select($.sel, "a", { a: "A", b: "B" })] }], 457 | ` 458 | assert.equal(pretty(input), output) 459 | }) 460 | 461 | test('select expression in multiline value', function() { 462 | const input = ftl` 463 | foo = 464 | Foo 465 | Bar { $sel -> 466 | *[a] A 467 | [b] B 468 | } 469 | ` 470 | const output = ftl` 471 | ["foo", { value: $ => ["Foo\\nBar ", select($.sel, "a", { a: "A", b: "B" })] }], 472 | ` 473 | assert.equal(pretty(input), output) 474 | }) 475 | 476 | test('nested select expression', function() { 477 | const input = ftl` 478 | foo = 479 | { $a -> 480 | *[a] 481 | { $b -> 482 | *[b] Foo 483 | } 484 | } 485 | ` 486 | const output = ftl` 487 | ["foo", { value: $ => [select($.a, "a", { a: select($.b, "b", { b: "Foo" }) })] }], 488 | ` 489 | assert.equal(pretty(input), output) 490 | }) 491 | 492 | test('selector external argument', function() { 493 | const input = ftl` 494 | foo = 495 | { $bar -> 496 | *[a] A 497 | } 498 | ` 499 | const output = ftl` 500 | ["foo", { value: $ => [select($.bar, "a", { a: "A" })] }], 501 | ` 502 | assert.equal(pretty(input), output) 503 | }) 504 | 505 | test('selector number expression', function() { 506 | const input = ftl` 507 | foo = 508 | { 1 -> 509 | *[a] A 510 | } 511 | ` 512 | const output = ftl` 513 | ["foo", { value: $ => [select(1, "a", { a: "A" })] }], 514 | ` 515 | assert.equal(pretty(input), output) 516 | }) 517 | 518 | test('selector string expression', function() { 519 | const input = ftl` 520 | foo = 521 | { "bar" -> 522 | *[a] A 523 | } 524 | ` 525 | const output = ftl` 526 | ["foo", { value: $ => [select("bar", "a", { a: "A" })] }], 527 | ` 528 | assert.equal(pretty(input), output) 529 | }) 530 | 531 | test('selector attribute expression', function() { 532 | const input = ftl` 533 | foo = 534 | { -bar.baz -> 535 | *[a] A 536 | } 537 | ` 538 | const output = ftl` 539 | ["foo", { value: $ => [select(R.get("-bar").attributes.baz(), "a", { a: "A" })] }], 540 | ` 541 | assert.equal(pretty(input), output) 542 | }) 543 | 544 | test('call expression', function() { 545 | const input = ftl` 546 | foo = { FOO() } 547 | ` 548 | const output = ftl` 549 | ["foo", { value: $ => [FOO($)] }], 550 | ` 551 | assert.equal(pretty(input), output) 552 | }) 553 | 554 | test('call expression with string expression', function() { 555 | const input = ftl` 556 | foo = { FOO("bar") } 557 | ` 558 | const output = ftl` 559 | ["foo", { value: $ => [FOO($, "bar")] }], 560 | ` 561 | assert.equal(pretty(input), output) 562 | }) 563 | 564 | test('call expression with number expression', function() { 565 | const input = ftl` 566 | foo = { FOO(1) } 567 | ` 568 | const output = ftl` 569 | ["foo", { value: $ => [FOO($, 1)] }], 570 | ` 571 | assert.equal(pretty(input), output) 572 | }) 573 | 574 | test('call expression with message reference', function() { 575 | const input = ftl` 576 | foo = { FOO(bar) } 577 | ` 578 | const output = ftl` 579 | ["foo", { value: $ => [FOO($, R.get("bar").value($))] }], 580 | ` 581 | assert.equal(pretty(input), output) 582 | }) 583 | 584 | test('call expression with external argument', function() { 585 | const input = ftl` 586 | foo = { FOO($bar) } 587 | ` 588 | const output = ftl` 589 | ["foo", { value: $ => [FOO($, $.bar)] }], 590 | ` 591 | assert.equal(pretty(input), output) 592 | }) 593 | 594 | test('call expression with number named argument', function() { 595 | const input = ftl` 596 | foo = { FOO(bar: 1) } 597 | ` 598 | const output = ftl` 599 | ["foo", { value: $ => [FOO({ ...$, bar: 1 })] }], 600 | ` 601 | assert.equal(pretty(input), output) 602 | }) 603 | 604 | test('call expression with string named argument', function() { 605 | const input = ftl` 606 | foo = { FOO(bar: "bar") } 607 | ` 608 | const output = ftl` 609 | ["foo", { value: $ => [FOO({ ...$, bar: "bar" })] }], 610 | ` 611 | assert.equal(pretty(input), output) 612 | }) 613 | 614 | test('call expression with two positional arguments', function() { 615 | const input = ftl` 616 | foo = { FOO(bar, baz) } 617 | ` 618 | const output = ftl` 619 | ["foo", { value: $ => [FOO($, R.get("bar").value($), R.get("baz").value($))] }], 620 | ` 621 | assert.equal(pretty(input), output) 622 | }) 623 | 624 | test('call expression with two named arguments', function() { 625 | const input = ftl` 626 | foo = { FOO(bar: "bar", baz: "baz") } 627 | ` 628 | const output = ftl` 629 | ["foo", { value: $ => [FOO({ ...$, bar: "bar", baz: "baz" })] }], 630 | ` 631 | assert.equal(pretty(input), output) 632 | }) 633 | 634 | test('call expression with positional and named arguments', function() { 635 | const input = ftl` 636 | foo = { FOO(bar, 1, baz: "baz") } 637 | ` 638 | const output = ftl` 639 | ["foo", { value: $ => [FOO({ ...$, baz: "baz" }, R.get("bar").value($), 1)] }], 640 | ` 641 | assert.equal(pretty(input), output) 642 | }) 643 | 644 | test('macro call', function() { 645 | const input = ftl` 646 | foo = { -term() } 647 | ` 648 | const output = ftl` 649 | ["foo", { value: $ => [R.get("-term").value()] }], 650 | ` 651 | assert.equal(pretty(input), output) 652 | }) 653 | 654 | test('nested placeables', function() { 655 | const input = ftl` 656 | foo = {{ FOO() }} 657 | ` 658 | const output = ftl` 659 | ["foo", { value: $ => [FOO($)] }], 660 | ` 661 | assert.equal(pretty(input), output) 662 | }) 663 | 664 | test('Backslash in TextElement', function() { 665 | const input = ftl` 666 | foo = \\{ placeable } 667 | ` 668 | const output = ftl` 669 | ["foo", { value: $ => ["\\\\", R.get("placeable").value($)] }], 670 | ` 671 | assert.equal(pretty(input), output) 672 | }) 673 | 674 | test('Escaped special char in StringLiteral', function() { 675 | const input = ftl` 676 | foo = { "Escaped \\" quote" } 677 | ` 678 | const output = ftl` 679 | ["foo", { value: $ => ["Escaped \\" quote"] }], 680 | ` 681 | assert.equal(pretty(input), output) 682 | }) 683 | 684 | test('Unicode escape sequence', function() { 685 | const input = ftl` 686 | foo = { "\\u0065" } 687 | ` 688 | const output = ftl` 689 | ["foo", { value: $ => ["\\u0065"] }], 690 | ` 691 | assert.equal(pretty(input), output) 692 | }) 693 | }) 694 | 695 | suite('compiler.expression', function() { 696 | let compiler, pretty 697 | 698 | setup(function() { 699 | const parser = new FluentParser() 700 | 701 | compiler = new FluentCompiler({ runtimeGlobals: ['BUILTIN'] }) 702 | pretty = function pretty(text) { 703 | const { 704 | value: { 705 | elements: [placeable] 706 | } 707 | } = parser.parseEntry(text) 708 | return compiler.expression(placeable.expression) 709 | } 710 | }) 711 | 712 | test('invalid expression', function() { 713 | assert.throws( 714 | () => compiler.expression(null), 715 | /Cannot read property 'type'/ 716 | ) 717 | assert.throws(() => compiler.expression({}), /Unknown expression type/) 718 | }) 719 | 720 | test('string expression', function() { 721 | const input = ftl` 722 | foo = { "str" } 723 | ` 724 | assert.equal(pretty(input), '"str"') 725 | }) 726 | 727 | test('number expression', function() { 728 | const input = ftl` 729 | foo = { 3 } 730 | ` 731 | assert.equal(pretty(input), '3') 732 | }) 733 | 734 | test('message reference', function() { 735 | const input = ftl` 736 | foo = { msg } 737 | ` 738 | assert.equal(pretty(input), 'R.get("msg").value($)') 739 | }) 740 | 741 | test('external argument', function() { 742 | const input = ftl` 743 | foo = { $ext } 744 | ` 745 | assert.equal(pretty(input), '$.ext') 746 | }) 747 | 748 | test('attribute expression', function() { 749 | const input = ftl` 750 | foo = { msg.attr } 751 | ` 752 | assert.equal(pretty(input), 'R.get("msg").attributes.attr($)') 753 | }) 754 | 755 | test('call expression', function() { 756 | const input = ftl` 757 | foo = { BUILTIN(3.14, kwarg: "value") } 758 | ` 759 | assert.equal(pretty(input), 'BUILTIN({ ...$, kwarg: "value" }, 3.14)') 760 | }) 761 | 762 | test('select expression', function() { 763 | const input = ftl` 764 | foo = 765 | { $num -> 766 | *[one] One 767 | } 768 | ` 769 | assert.equal(pretty(input), 'select($.num, "one", { one: "One" })') 770 | }) 771 | }) 772 | 773 | suite('Compile padding around comments', function() { 774 | let pretty 775 | 776 | setup(function() { 777 | const parser = new FluentParser() 778 | const compiler = new FluentCompiler({ 779 | withJunk: false 780 | }) 781 | 782 | pretty = function pretty(text) { 783 | const res = parser.parse(text) 784 | return trimModuleHeaders(compiler.compile(undefined, res)) 785 | } 786 | }) 787 | 788 | test('standalone comment has not padding when first', function() { 789 | const input = ftl` 790 | # Comment A 791 | 792 | foo = Foo 793 | 794 | # Comment B 795 | 796 | bar = Bar 797 | ` 798 | const output = ftl` 799 | // Comment A 800 | 801 | ["foo", { value: $ => ["Foo"] }], 802 | 803 | // Comment B 804 | 805 | ["bar", { value: $ => ["Bar"] }], 806 | ` 807 | assert.equal(pretty(input), output) 808 | // Run again to make sure the same instance of the compiler doesn't keep 809 | // state about how many entires is has already compiled. 810 | assert.equal(pretty(input), output) 811 | }) 812 | 813 | test('group comment has not padding when first', function() { 814 | const input = ftl` 815 | ## Group A 816 | 817 | foo = Foo 818 | 819 | ## Group B 820 | 821 | bar = Bar 822 | ` 823 | const output = ftl` 824 | // ## Group A 825 | 826 | ["foo", { value: $ => ["Foo"] }], 827 | 828 | // ## Group B 829 | 830 | ["bar", { value: $ => ["Bar"] }], 831 | ` 832 | assert.equal(pretty(input), output) 833 | assert.equal(pretty(input), output) 834 | }) 835 | 836 | test('resource comment has not padding when first', function() { 837 | const input = ftl` 838 | ### Resource Comment A 839 | 840 | foo = Foo 841 | 842 | ### Resource Comment B 843 | 844 | bar = Bar 845 | ` 846 | const output = ftl` 847 | // ### Resource Comment A 848 | 849 | ["foo", { value: $ => ["Foo"] }], 850 | 851 | // ### Resource Comment B 852 | 853 | ["bar", { value: $ => ["Bar"] }], 854 | ` 855 | assert.equal(pretty(input), output) 856 | assert.equal(pretty(input), output) 857 | }) 858 | }) 859 | 860 | suite('compiler.variantKey', function() { 861 | let compiler, prettyVariantKey 862 | 863 | setup(function() { 864 | let parser = new FluentParser() 865 | 866 | compiler = new FluentCompiler() 867 | prettyVariantKey = function(text, index) { 868 | let pattern = parser.parseEntry(text).value 869 | let variants = pattern.elements[0].expression.variants 870 | return compiler.variantKey(variants[index].key) 871 | } 872 | }) 873 | 874 | test('invalid expression', function() { 875 | assert.throws( 876 | () => compiler.variantKey(null), 877 | /Cannot read property 'type'/ 878 | ) 879 | assert.throws(() => compiler.variantKey({}), /Unknown variant key type/) 880 | }) 881 | 882 | test('identifiers', function() { 883 | const input = ftl` 884 | foo = { $num -> 885 | [one] One 886 | *[other] Other 887 | } 888 | ` 889 | assert.equal(prettyVariantKey(input, 0), 'one') 890 | assert.equal(prettyVariantKey(input, 1), 'other') 891 | }) 892 | 893 | test('number literals', function() { 894 | const input = ftl` 895 | foo = { $num -> 896 | [-123456789] Minus a lot 897 | [0] Zero 898 | *[3.14] Pi 899 | [007] James 900 | } 901 | ` 902 | assert.equal(prettyVariantKey(input, 0), '-123456789') 903 | assert.equal(prettyVariantKey(input, 1), '0') 904 | assert.equal(prettyVariantKey(input, 2), '3.14') 905 | assert.equal(prettyVariantKey(input, 3), '007') 906 | }) 907 | }) 908 | --------------------------------------------------------------------------------