fluent-transpiler 0.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2022 will Farrell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # fluent-transpiler
2
+ Transpile Fluent (ftl) files into optomized, tree-shakable, JavaScript EcmaScript Modules (esm).
3
+
4
+ ## Install
5
+ ```bash
6
+ npm i -D fluent-transpiler
7
+ ```
8
+
9
+ ## CLI
10
+ ```bash
11
+ Usage: ftl [options] <input>
12
+
13
+ Compile Fluent (.ftl) files to JavaScript (.js or .mjs)
14
+
15
+ Arguments:
16
+ input Path to the Fluent file to compile
17
+
18
+ Options:
19
+ --locale <locale...> What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA
20
+ --comments Include comments in output file.
21
+ --variable-notation <variableNotation> What variable notation to use with exports (choices: "camelCase", "pascalCase", "constantCase",
22
+ "snakeCase", default: "camelCase")
23
+ --disable-minify If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.
24
+ --use-isolating Wrap placeable with \u2068 and \u2069.
25
+ -o, --output <output> Path to store the resulting JavaScript file. Will be in ESM.
26
+ -h, --help display help for command
27
+ ```
28
+
29
+
30
+ ## NodeJS
31
+
32
+ | Option | Description |
33
+ |--------|-------------|
34
+ | locale | What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA |
35
+ | comments | Include comments in output file. Default: true |
36
+ | disableMinify | If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`. Default: each exported message could be a different type based on what is needed to generate the message (`string`, `object`, `() => ''`, `() => ({})`) |
37
+ | errorOnJunk | Throw error when `Junk` is parsed. Default: true |
38
+ | variableNotation | What variable notation to use with exports. Default: `camelCase` |
39
+ | useIsolating | Wrap placeable with \u2068 and \u2069. Default: false |
40
+ | exportDefault | Allows the overwriting of the `export default` to allow for custom uses. Default: See code |
41
+
42
+ ```javascript
43
+ import {readFile,writeFile} from 'node:fs/promises'
44
+ import fluentTranspiler from 'fluent-transpiler'
45
+
46
+ const ftl = await readFile('./path/to/en.ftl', {encoding:'utf8'})
47
+ const js = fluentTranspiler(ftl, {locale:'en-CA'})
48
+ await writeFile('./path/to/en.mjs', js, 'utf8')
49
+ ```
package/cli.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createReadStream } from 'fs'
4
+ import { stat, readFile, writeFile } from 'node:fs/promises'
5
+ import { Command, Option } from 'commander'
6
+ import compile from './index.js'
7
+
8
+ const fileExists = async (filepath) => {
9
+ const stats = await stat(filepath)
10
+ if (!stats.isFile()) {
11
+ throw new Error(`${filepath} is not a file`)
12
+ }
13
+ }
14
+
15
+ new Command()
16
+ .name('ftl')
17
+ .description('Compile Fluent (.ftl) files to JavaScript (.js or .mjs)')
18
+ //.version(package.version)
19
+ .argument('<input>', 'Path to the Fluent file to compile')
20
+ .requiredOption('--locale <locale...>', 'What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA')
21
+ .addOption(new Option('--comments', 'Include comments in output file.')
22
+ .preset(true)
23
+ )
24
+ .addOption(new Option('--variable-notation <variableNotation>', 'What variable notation to use with exports')
25
+ .choices(['camelCase','pascalCase','constantCase','snakeCase'])
26
+ .default('camelCase')
27
+ )
28
+ .addOption(new Option('--disable-minify', 'If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.')
29
+ .preset(true)
30
+ )
31
+ .addOption(new Option('--use-isolating', 'Wrap placeable with \\u2068 and \\u2069.')
32
+ .preset(true)
33
+ )
34
+ .addOption(new Option('-o, --output <output>', 'Path to store the resulting JavaScript file. Will be in ESM.'))
35
+ .action(async (input, options) => {
36
+ await fileExists(input)
37
+
38
+ const ftl = await readFile(input, {encoding:'utf8'})
39
+
40
+ const js = compile(ftl, options)
41
+ if (options.output) {
42
+ await writeFile(options.output, js, 'utf8')
43
+ } else {
44
+ console.log(js)
45
+ }
46
+
47
+ })
48
+ .parse()
49
+
package/index.js ADDED
@@ -0,0 +1,315 @@
1
+ import { parse } from '@fluent/syntax'
2
+ import { camelCase, pascalCase, constantCase, snakeCase } from 'change-case'
3
+
4
+ const exportDefault = `(id, params) => {
5
+ const source = __exports[id] ?? __exports['_'+id]
6
+ if (typeof source === 'undefined') return '*** '+id+' ***'
7
+ if (typeof source === 'function') return source(params)
8
+ return source
9
+ }
10
+ `
11
+ export const compile = (src, opts) => {
12
+ const options = {
13
+ variableNotation: 'camelCase',
14
+ disableMinify: false, // TODO needs better name
15
+ params: 'params',
16
+ comments: true,
17
+ errorOnJunk: true,
18
+ useIsolating: false,
19
+ exportDefault,
20
+ ...opts,
21
+ }
22
+ if (!Array.isArray(options.locale)) options.locale = [options.locale]
23
+
24
+ const metadata = {}
25
+ const functions = {} // global functions
26
+ let variable
27
+
28
+ const regexpValidVariable = /^[a-zA-Z]+[a-zA-Z0-9]*$/
29
+ const compileAssignment = (data) => {
30
+ variable = compileType(data)
31
+ metadata[variable] = {
32
+ id: data.name,
33
+ term: false,
34
+ params: false
35
+ }
36
+ return variable
37
+ }
38
+
39
+ const compileFunctionArguments = (data) => {
40
+ const positional = data.arguments?.positional.map(data => {
41
+ return types[data.type](data)
42
+ })
43
+ const named = data.arguments?.named.reduce((obj, data) => {
44
+ // NamedArgument
45
+ const key = data.name.name
46
+ const value = compileType(data.value, data.type)
47
+ obj[key] = value
48
+ return obj
49
+ }, {})
50
+ return {positional, named}
51
+ }
52
+
53
+ const compileType = (data, parent) => {
54
+ try {
55
+ return types[data.type](data, parent)
56
+ } catch(e) {
57
+ console.error('Error:', e.message, data, e.stack)
58
+ throw new Error(e.message, {cause:data, stack:e.stack})
59
+ }
60
+ }
61
+
62
+ const types = {
63
+ Identifier: (data) => {
64
+ const value = variableNotation[options.variableNotation](data.name)
65
+ // Check for reserved words - TODO add in rest
66
+ if (['const','default','enum','if'].includes(value)) {
67
+ return '_'+value
68
+ }
69
+ return value
70
+ },
71
+ Attribute: (data) => {
72
+ const key = compileType(data.id)
73
+ const value = compileType(data.value, data.type)
74
+ return ` ${key}: ${value}`
75
+ },
76
+ Pattern: (data, parent) => {
77
+ return '`' + data.elements.map(data => {
78
+ return compileType(data, parent)
79
+ }).join('') + '`'
80
+ },
81
+ // resources
82
+ Term: (data) => {
83
+ const assignment = compileAssignment(data.id)
84
+ const templateStringLiteral = compileType(data.value)
85
+ metadata[variable].term = true
86
+ if (metadata[assignment].params) {
87
+ return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`
88
+ }
89
+ return `const ${assignment} = ${templateStringLiteral}\n`
90
+ },
91
+ Message: (data) => {
92
+ const assignment = compileAssignment(data.id)
93
+ const templateStringLiteral = data.value && compileType(data.value, data.type)
94
+ metadata[assignment].attributes = data.attributes.length
95
+ let attributes = {}
96
+ if (metadata[assignment].attributes) {
97
+ // use Object.create(null) ?
98
+ attributes = `{\n${data.attributes.map(data => {
99
+ return ' '+compileType(data)
100
+ }).join(',\n')}\n }`
101
+ }
102
+ //
103
+ if (!options.disableMinify) {
104
+ if (metadata[variable].attributes) {
105
+ if (metadata[assignment].params) {
106
+ return `export const ${assignment} = (${options.params}) => ({
107
+ value:${templateStringLiteral},
108
+ attributes:${attributes}
109
+ })\n`
110
+ }
111
+ return `export const ${assignment} = {
112
+ value: ${templateStringLiteral},
113
+ attributes: ${attributes}
114
+ }\n`
115
+ }
116
+ if (metadata[assignment].params) {
117
+ return `export const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`
118
+ }
119
+ return `export const ${assignment} = ${templateStringLiteral}\n`
120
+ }
121
+ // consistent API
122
+ return `export const ${variable} = (${metadata[assignment].params ? options.params : ''}) => ({
123
+ value:${templateStringLiteral},
124
+ attributes:${attributes}
125
+ })\n`
126
+ },
127
+ Comment: (data) => {
128
+ if (options.comments) return `// # ${data.content}\n`
129
+ return ''
130
+ },
131
+ GroupComment: (data) => {
132
+ if (options.comments) return `// ## ${data.content}\n`
133
+ return ''
134
+ },
135
+ ResourceComment: (data) => {
136
+ if (options.comments) return `// ### ${data.content}\n`
137
+ return ''
138
+ },
139
+ Junk: (data) => {
140
+ if (options.errorOnJunk) {
141
+ throw new Error('Junk found', {cause:data})
142
+ }
143
+ console.error('Error: Skipping Junk', JSON.stringify(data, null, 2))
144
+ return ''
145
+ },
146
+ // Element
147
+ TextElement: (data) => {
148
+ return data.value
149
+ },
150
+ Placeable: (data, parent) => {
151
+ return `${options.useIsolating ? '\u2068' : '' }\${${compileType(data.expression, parent)}}${options.useIsolating ? '\u2069' : '' }`
152
+ },
153
+ // Expression
154
+ StringLiteral: (data, parent) => {
155
+ // JSON.stringify at parent level
156
+ if (['NamedArgument'].includes(parent)) {
157
+ return `${data.value}`
158
+ }
159
+ return `"${data.value}"`
160
+ },
161
+ NumberLiteral: (data) => {
162
+ const decimal = Number.parseFloat(data.value)
163
+ const number = Number.isInteger(decimal) ? Number.parseInt(data.value) : decimal
164
+ return Intl.NumberFormat(options.locale).format(number)
165
+ },
166
+ VariableReference: (data, parent) => {
167
+ functions.__formatVariable = true
168
+ metadata[variable].params = true
169
+ const value = `${options.params}?.${data.id.name}`
170
+ if (['Message','Variant','Attribute'].includes(parent)) {
171
+ return `__formatVariable(${value})`
172
+ }
173
+ return value
174
+ },
175
+ MessageReference: (data) => {
176
+ const messageName = compileType(data.id)
177
+ metadata[variable].params ||= metadata[messageName].params
178
+ if (!options.disableMinify) {
179
+ if (metadata[messageName].params) {
180
+ return `${messageName}(${options.params})`
181
+ }
182
+ return `${messageName}`
183
+ }
184
+ return `${messageName}(${metadata[messageName].params ? options.params : ''})`
185
+ },
186
+ TermReference: (data) => {
187
+ const termName = compileType(data.id)
188
+ const {named} = compileFunctionArguments(data)
189
+ if (!options.disableMinify) {
190
+ if (metadata[termName].params) {
191
+ return `${termName}(${JSON.stringify(named)})`
192
+ }
193
+ return `${termName}`
194
+ }
195
+ return `${termName}(${named ? JSON.stringify(named) : ''})`
196
+ },
197
+ NamedArgument:(data) => {
198
+ // Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
199
+ const key = data.name.name // Don't transform value
200
+ const value = compileType(data.value, data.type)
201
+ return `${key}: ${value}`
202
+ },
203
+ SelectExpression: (data) => {
204
+ functions.__select = true
205
+ metadata[variable].params = true
206
+ const value = compileType(data.selector)
207
+ //const options = data.selector
208
+ let fallback
209
+ return `__select(\n ${value},\n {\n${data.variants.filter(data => {
210
+ if (data.default) {
211
+ fallback = compileType(data.value, data.type)
212
+ }
213
+ return !data.default
214
+ }).map(data => {
215
+ return ' '+compileType(data)
216
+ }).join(',\n')}\n },\n ${fallback}\n )`
217
+ },
218
+ Variant: (data, parent) => {
219
+ // Inconsistent: `Variant` uses `key` instead of `id` for Identifier
220
+ const key = compileType(data.key)
221
+ const value = compileType(data.value, data.type)
222
+ return ` '${key}': ${value}`
223
+ },
224
+ FunctionReference: (data) => {
225
+ return `${types[data.id.name](compileFunctionArguments(data))}`
226
+ },
227
+ // Functions
228
+ DATETIME: (data) => {
229
+ functions.__formatDateTime = true
230
+ const {positional, named} = data
231
+ const value = positional.shift()
232
+ return `__formatDateTime(${value}, ${JSON.stringify(named)})`
233
+ },
234
+ NUMBER: (data) => {
235
+ functions.__formatNumber = true
236
+ const {positional, named} = data
237
+ const value = positional.shift()
238
+ return `__formatNumber(${value}, ${JSON.stringify(named)})`
239
+ },
240
+ }
241
+
242
+ if (/\t/.test(src)) {
243
+ console.error('Source file contains tab characters (\t), replacing with <space>x4')
244
+ src = src.replace(/\t/g, ' ')
245
+ }
246
+
247
+ const {body} = parse(src)
248
+
249
+ let translations = ``
250
+ for(const data of body) {
251
+ translations += compileType(data)
252
+ }
253
+
254
+ let output = ``
255
+ if (functions.__formatVariable || functions.__formatDateTime || functions.__formatNumber) {
256
+ output += `const __locales = ${JSON.stringify(opts.locale)}\n`
257
+ }
258
+
259
+ if (functions.__formatDateTime) {
260
+ output += `
261
+ const __formatDateTime = (value, options) => {
262
+ return new Intl.DateTimeFormat(__locales, options).format(value)
263
+ }
264
+ `
265
+ }
266
+ if (functions.__formatVariable || functions.__formatNumber) {
267
+ output += `
268
+ const __formatNumber = (value, options) => {
269
+ return new Intl.NumberFormat(__locales, options).format(value)
270
+ }
271
+ `
272
+ }
273
+ if (functions.__formatVariable) {
274
+ output += `
275
+ const __formatVariable = (value) => {
276
+ if (typeof value === 'string') return value
277
+ const decimal = Number.parseFloat(value)
278
+ const number = Number.isInteger(decimal) ? Number.parseInt(value) : decimal
279
+ return __formatNumber(number)
280
+ }
281
+ `
282
+ }
283
+ if (functions.__select) {
284
+ output += `
285
+ const __select = (value, cases, fallback, options) => {
286
+ const pluralRules = new Intl.PluralRules(__locales, options)
287
+ const rule = pluralRules.select(value)
288
+ return cases[value] ?? cases[rule] ?? fallback
289
+ }
290
+ `
291
+ }
292
+ output += `\n`+translations
293
+ output += `const __exports = {${Object.keys(metadata)
294
+ .filter(key => !metadata[key].term)
295
+ .map(key => {
296
+ if (key === metadata[key].id) return key
297
+ return `'${metadata[key].id}':${key}`
298
+ })
299
+ .join(', ')
300
+ }}\n`
301
+ output += `\nexport default ${options.exportDefault}`
302
+
303
+ return output
304
+ }
305
+
306
+
307
+ const variableNotation = {
308
+ camelCase,
309
+ pascalCase,
310
+ snakeCase,
311
+ constantCase
312
+ }
313
+ // for `export defaults`
314
+
315
+ export default compile
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "fluent-transpiler",
3
+ "version": "0.0.0",
4
+ "description": "Transpile Fluent (ftl) files into optomized, tree-shakable, JavaScript EcmaScript Modules (esm).",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "ftl": "cli.js"
9
+ },
10
+ "scripts": {
11
+ "test": "npm run test:cli && npm run test:unit",
12
+ "test:cli": "./cli.js --locale en-CA --locale en --use-isolating --variable-notation camelCase test/files/index.ftl --output test/files/index.mjs",
13
+ "test:unit": "c8 node --test"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/willfarrell/fluent-transpiler.git"
18
+ },
19
+ "keywords": [
20
+ "Fluent",
21
+ "@fluent",
22
+ "ftl",
23
+ "localization",
24
+ "l10n",
25
+ "internationalization",
26
+ "i18n",
27
+ "ast",
28
+ "compiler",
29
+ "transpiler"
30
+ ],
31
+ "author": "willfarrell",
32
+ "license": "MIT",
33
+ "bugs": {
34
+ "url": "https://github.com/willfarrell/fluent-transpiler/issues"
35
+ },
36
+ "homepage": "https://middy.js.org",
37
+ "dependencies": {
38
+ "@fluent/syntax": "0.18.1",
39
+ "change-case": "4.1.2",
40
+ "commander": "9.4.0"
41
+ },
42
+ "devDependencies": {
43
+ "@fluent/bundle": "^0.17.1",
44
+ "c8": "^7.12.0"
45
+ }
46
+ }
@@ -0,0 +1,117 @@
1
+ # https://projectfluent.org/fluent/guide/
2
+ # Writing Text
3
+ text = text: hard coded.
4
+
5
+ ## Placeables
6
+ replaceParam = param: { $string } | { $integer } | {$decimal} | {$number} .
7
+
8
+ -term = Firefox
9
+ replaceTerm = term: { -term }.
10
+
11
+ -brand-name =
12
+ { $case ->
13
+ *[nominative] Firefox
14
+ [locative] Firefoksie
15
+ }
16
+ parameterized-terms = Informacje o { -brand-name(case: "locative") }.
17
+
18
+ ### Message References
19
+ messageValue = message: { replaceTerm }
20
+ messageNestedParamValue = message: { replaceParam }
21
+
22
+
23
+ ## Special Characters
24
+ openingBrace = This message features an opening curly brace: {"{"}.
25
+ closingBrace = This message features a closing curly brace: {"}"}.
26
+
27
+ blankIsRemoved = This message starts with no blanks.
28
+ blankIsPreserved = {" "}This message starts with 4 spaces.
29
+
30
+ leadingBracket =
31
+ This message has an opening square bracket
32
+ at the beginning of the third line:
33
+ {"["}.
34
+
35
+ literalQuote = Text in "double quotes".
36
+ # This is OK, but cryptic and hard to read and edit.
37
+ literalEscapedQuote = Text in {"\""}double quotes{"\""}.
38
+
39
+
40
+ privacyLabel = Privacy{"\u00A0"}Policy
41
+
42
+ # The dash character is an EM DASH but depending on the font face,
43
+ # it might look like an EN DASH.
44
+ dash = It's a dash—or is it?
45
+
46
+ dashUnicode = It's a dash{"\u2014"}or is it?
47
+
48
+ emoji = 😂
49
+ emojiUnicode = {"\u01F602"}
50
+
51
+ ## Multiline Text
52
+ singleLine = Text can be written in a single line.
53
+
54
+ multiLine = Text can also span multiple lines
55
+ as long as each new line is indented
56
+ by at least one space.
57
+
58
+ block =
59
+ Sometimes it's more readable to format
60
+ multiline text as a "block", which means
61
+ starting it on a new line. All lines must
62
+ be indented by at least one space.
63
+
64
+ leadingBlankSpaces = This message's value starts with the word "This".
65
+ leadingBlankLines =
66
+
67
+
68
+ This message's value starts with the word "This".
69
+ The blank lines under the identifier are ignored.
70
+
71
+ blankLines =
72
+
73
+ The blank line above this line is ignored.
74
+ This is a second line of the value.
75
+
76
+ The blank line above this line is preserved.
77
+
78
+ multiLineIndent =
79
+ This message has 4 spaces of indent
80
+ on the second line of its value.
81
+
82
+ ## Functions
83
+ timeElapsed = Time elapsed: { NUMBER($number, maximumFractionDigits: 0) }s.
84
+
85
+ todayIs = Today is { DATETIME($date) }
86
+
87
+ ## Selectors
88
+ selectorNumberCardinal =
89
+ { $number ->
90
+ [zero] There are {$number} (zero).
91
+ [one] There are {$number} (one).
92
+ *[other] There are { $number } (other).
93
+ }
94
+
95
+ selectorNumberOrdinal =
96
+ { NUMBER($number, type: "ordinal") ->
97
+ [-1] There are {$number} (-1).
98
+ [zero] There are {$number} (zero).
99
+ [one] There are {$number} (one).
100
+ [two] There are {$number} (two).
101
+ [3.0] There are {$number} (3).
102
+ [few] There are {$number} (few).
103
+ [many] There are {$number} (many).
104
+ [toomany] There are {$number} (toomany).
105
+ *[other] There are { $number } (other).
106
+ }
107
+
108
+ ## Attributes
109
+ loginInput = Predefined value
110
+ .placeholder = email@example.com
111
+ .ariaLabel = Login input value
112
+ .title = Type your login email
113
+
114
+ attributeHowTo =
115
+ To add an attribute to this messages, write
116
+ {".attr = Value"} on a new line.
117
+ .attr = An actual attribute (not part of the text value above)
@@ -0,0 +1,122 @@
1
+ const __locales = "en-CA"
2
+
3
+ const __formatDateTime = (value, options) => {
4
+ return new Intl.DateTimeFormat(__locales, options).format(value)
5
+ }
6
+
7
+ const __formatNumber = (value, options) => {
8
+ return new Intl.NumberFormat(__locales, options).format(value)
9
+ }
10
+
11
+ const __formatVariable = (value) => {
12
+ if (typeof value === 'string') return value
13
+ const decimal = Number.parseFloat(value)
14
+ const number = Number.isInteger(decimal) ? Number.parseInt(value) : decimal
15
+ return __formatNumber(number)
16
+ }
17
+
18
+ const __select = (value, cases, fallback, options) => {
19
+ const pluralRules = new Intl.PluralRules(__locales, options)
20
+ const rule = pluralRules.select(value)
21
+ return cases[value] ?? cases[rule] ?? fallback
22
+ }
23
+
24
+ export const text = `text: hard coded.`
25
+ // ## Placeables
26
+ export const replaceParam = (params) => `param: ${__formatVariable(params?.string)} | ${__formatVariable(params?.integer)} | ${__formatVariable(params?.decimal)} | ${__formatVariable(params?.number)} .`
27
+ const term = `Firefox`
28
+ export const replaceTerm = `term: ${term}.`
29
+ const brandName = (params) => `${__select(
30
+ params?.case,
31
+ {
32
+ 'locative': `Firefoksie`
33
+ },
34
+ `Firefox`
35
+ )}`
36
+ export const parameterizedTerms = `Informacje o ${brandName({"case":"locative"})}.`
37
+ // ### Message References
38
+ export const messageValue = `message: ${replaceTerm}`
39
+ export const messageNestedParamValue = (params) => `message: ${replaceParam(params)}`
40
+ // ## Special Characters
41
+ export const openingBrace = `This message features an opening curly brace: ${"{"}.`
42
+ export const closingBrace = `This message features a closing curly brace: ${"}"}.`
43
+ export const blankIsRemoved = `This message starts with no blanks.`
44
+ export const blankIsPreserved = `${" "}This message starts with 4 spaces.`
45
+ export const leadingBracket = `This message has an opening square bracket
46
+ at the beginning of the third line:
47
+ ${"["}.`
48
+ export const literalQuote = `Text in "double quotes".`
49
+ export const literalEscapedQuote = `Text in ${"\""}double quotes${"\""}.`
50
+ export const privacyLabel = `Privacy${"\u00A0"}Policy`
51
+ export const dash = `It's a dash—or is it?`
52
+ export const dashUnicode = `It's a dash${"\u2014"}or is it?`
53
+ export const emoji = `😂`
54
+ export const emojiUnicode = `${"\u01F602"}`
55
+ // ## Multiline Text
56
+ export const singleLine = `Text can be written in a single line.`
57
+ export const multiLine = `Text can also span multiple lines
58
+ as long as each new line is indented
59
+ by at least one space.`
60
+ export const block = `Sometimes it's more readable to format
61
+ multiline text as a "block", which means
62
+ starting it on a new line. All lines must
63
+ be indented by at least one space.`
64
+ export const leadingBlankSpaces = `This message's value starts with the word "This".`
65
+ export const leadingBlankLines = `This message's value starts with the word "This".
66
+ The blank lines under the identifier are ignored.`
67
+ export const blankLines = `The blank line above this line is ignored.
68
+ This is a second line of the value.
69
+
70
+ The blank line above this line is preserved.`
71
+ export const multiLineIndent = `This message has 4 spaces of indent
72
+ on the second line of its value.`
73
+ // ## Functions
74
+ export const timeElapsed = (params) => `Time elapsed: ${__formatNumber(params?.number, {"maximumFractionDigits":"0"})}s.`
75
+ export const todayIs = (params) => `Today is ${__formatDateTime(params?.date, {})}`
76
+ // ## Selectors
77
+ export const selectorNumberCardinal = (params) => `${__select(
78
+ params?.number,
79
+ {
80
+ 'zero': `There are ${__formatVariable(params?.number)} (zero).`,
81
+ 'one': `There are ${__formatVariable(params?.number)} (one).`
82
+ },
83
+ `There are ${__formatVariable(params?.number)} (other).`
84
+ )}`
85
+ export const selectorNumberOrdinal = (params) => `${__select(
86
+ __formatNumber(params?.number, {"type":"ordinal"}),
87
+ {
88
+ '-1': `There are ${__formatVariable(params?.number)} (-1).`,
89
+ 'zero': `There are ${__formatVariable(params?.number)} (zero).`,
90
+ 'one': `There are ${__formatVariable(params?.number)} (one).`,
91
+ 'two': `There are ${__formatVariable(params?.number)} (two).`,
92
+ '3': `There are ${__formatVariable(params?.number)} (3).`,
93
+ 'few': `There are ${__formatVariable(params?.number)} (few).`,
94
+ 'many': `There are ${__formatVariable(params?.number)} (many).`,
95
+ 'toomany': `There are ${__formatVariable(params?.number)} (toomany).`
96
+ },
97
+ `There are ${__formatVariable(params?.number)} (other).`
98
+ )}`
99
+ // ## Attributes
100
+ export const loginInput = {
101
+ value: `Predefined value`,
102
+ attributes: {
103
+ placeholder: `email@example.com`,
104
+ ariaLabel: `Login input value`,
105
+ title: `Type your login email`
106
+ }
107
+ }
108
+ export const attributeHowTo = {
109
+ value: `To add an attribute to this messages, write
110
+ ${".attr = Value"} on a new line.`,
111
+ attributes: {
112
+ attr: `An actual attribute (not part of the text value above)`
113
+ }
114
+ }
115
+ const __exports = {text, replaceParam, replaceTerm, 'parameterized-terms':parameterizedTerms, messageValue, messageNestedParamValue, openingBrace, closingBrace, blankIsRemoved, blankIsPreserved, leadingBracket, literalQuote, literalEscapedQuote, privacyLabel, dash, dashUnicode, emoji, emojiUnicode, singleLine, multiLine, block, leadingBlankSpaces, leadingBlankLines, blankLines, multiLineIndent, timeElapsed, todayIs, selectorNumberCardinal, selectorNumberOrdinal, loginInput, attributeHowTo}
116
+
117
+ export default (id, params) => {
118
+ const source = __exports[id] ?? __exports['_'+id]
119
+ if (typeof source === 'undefined') return '*** '+id+' ***'
120
+ if (typeof source === 'function') return source(params)
121
+ return source
122
+ }
package/test/index.js ADDED
@@ -0,0 +1,84 @@
1
+ import test from 'node:test'
2
+ import { deepEqual } from 'node:assert'
3
+ import {readFile,writeFile} from 'node:fs/promises'
4
+ import compile from '../index.js'
5
+
6
+ import { FluentBundle, FluentResource } from "@fluent/bundle"
7
+
8
+ // input
9
+ const ftl = await readFile('./test/files/index.ftl', {encoding:'utf8'})
10
+
11
+ // compiled
12
+ const js = compile(ftl, {locale:'en-CA', variableNotation: 'camelCase', useIsolating:false})
13
+ await writeFile('./test/files/index.mjs', js, 'utf8')
14
+ const {default:fluentCompiled} = await import('./files/index.mjs') // TODO fix default
15
+
16
+ // core
17
+ let bundle = new FluentBundle('en-CA', {useIsolating:false});
18
+ let errors = bundle.addResource(new FluentResource(ftl));
19
+ if (errors.length) {
20
+ console.log('Error: bundle.addResource', errors)
21
+ }
22
+ const fluentBundle = (id, params) => {
23
+ const message = bundle.getMessage(id)
24
+ if (!message) {
25
+ console.log('Error', id, message)
26
+ return 'error'
27
+ }
28
+ const attributes = {}
29
+ for(const attr in message.attributes) {
30
+ attributes[attr] = bundle.formatPattern(message.attributes[attr], params)
31
+ }
32
+ const value = bundle.formatPattern(message.value, params)
33
+ if (Object.keys(message.attributes).length) {
34
+ return {value, attributes}
35
+ }
36
+ return value
37
+ }
38
+
39
+ // compile
40
+ test(`Should throw error when Junk is parsed`, async (t) => {
41
+ try {
42
+ const js = compile(`-brand-name = {}`, {locale:'en-CA', errorOnJunk:true})
43
+ console.log(js)
44
+ throw new Error('fail')
45
+ } catch (e) {
46
+ deepEqual(e.message, 'Junk found')
47
+ }
48
+ })
49
+
50
+ test(`Should include comments`, async (t) => {
51
+ const js = compile(`
52
+ # Comment
53
+ ## GroupComment
54
+ ### ResourceComment
55
+ `, {locale:'en-CA', comments:true})
56
+ deepEqual(js.includes(`// # Comment
57
+ // ## GroupComment
58
+ // ### ResourceComment`), true)
59
+
60
+ })
61
+
62
+ // tests
63
+ const params = {string:'0.0',integer:-2, decimal: 3.5, number:9999999.00, date: new Date()}
64
+ for (const id of Object.keys(fluentCompiled)) {
65
+ if (id === 'default') continue
66
+ test(`Should format ${id}`, async (t) => {
67
+ deepEqual(fluentCompiled(id, params), fluentBundle(id, params))
68
+ })
69
+ }
70
+
71
+ // selector
72
+ for(const param of [-1,0,1,2,3,4,5,10]) {
73
+ let id = 'selectorNumberCardinal'
74
+ test(`Should format ${id} with ${param}`, async (t) => {
75
+ params.number = param
76
+ deepEqual(fluentCompiled(id, params), fluentBundle(id, params))
77
+ })
78
+
79
+ id = 'selectorNumberOrdinal'
80
+ test(`Should format ${id} with ${param}`, async (t) => {
81
+ params.number = param
82
+ deepEqual(fluentCompiled(id, params), fluentBundle(id, params))
83
+ })
84
+ }