fluent-transpiler 0.0.1 → 0.1.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/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # fluent-transpiler
2
- Transpile Fluent (ftl) files into optomized, tree-shakable, JavaScript EcmaScript Modules (esm).
2
+
3
+ Transpile Fluent (ftl) files into optimized, tree-shakable, JavaScript EcmaScript Modules (esm).
3
4
 
4
5
  ## Install
6
+
5
7
  ```bash
6
8
  npm i -D fluent-transpiler
7
9
  ```
8
10
 
9
11
  ## CLI
12
+
10
13
  ```bash
11
14
  Usage: ftl [options] <input>
12
15
 
@@ -24,26 +27,25 @@ Options:
24
27
  --use-isolating Wrap placeable with \u2068 and \u2069.
25
28
  -o, --output <output> Path to store the resulting JavaScript file. Will be in ESM.
26
29
  -h, --help display help for command
27
- ```
28
-
30
+ ```
29
31
 
30
32
  ## NodeJS
31
33
 
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 |
34
+ | Option | Description |
35
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
36
+ | locale | What locale(s) to be used. Multiple can be set to allow for fallback. i.e. en-CA |
37
+ | comments | Include comments in output file. Default: true |
38
+ | 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`, `() => ''`, `() => ({})`) |
39
+ | errorOnJunk | Throw error when `Junk` is parsed. Default: true |
40
+ | variableNotation | What variable notation to use with exports. Default: `camelCase` |
41
+ | useIsolating | Wrap placeable with \u2068 and \u2069. Default: false |
42
+ | exportDefault | Allows the overwriting of the `export default` to allow for custom uses. Default: See code |
41
43
 
42
44
  ```javascript
43
- import {readFile,writeFile} from 'node:fs/promises'
45
+ import { readFile, writeFile } from 'node:fs/promises'
44
46
  import fluentTranspiler from 'fluent-transpiler'
45
47
 
46
- const ftl = await readFile('./path/to/en.ftl', {encoding:'utf8'})
47
- const js = fluentTranspiler(ftl, {locale:'en-CA'})
48
+ const ftl = await readFile('./path/to/en.ftl', { encoding: 'utf8' })
49
+ const js = fluentTranspiler(ftl, { locale: 'en-CA' })
48
50
  await writeFile('./path/to/en.mjs', js, 'utf8')
49
- ```
51
+ ```
package/cli.js CHANGED
@@ -21,9 +21,14 @@ new Command()
21
21
  .addOption(new Option('--comments', 'Include comments in output file.')
22
22
  .preset(true)
23
23
  )
24
+ .addOption(new Option('--include <includeMessages...>', 'Allowed messages to be included. Default to include all.'))
25
+ .addOption(new Option('--exclude <excludeMessages...>', 'Ignored messages to be excluded. Default to exclude none.'))
26
+ /*.addOption(new Option('--tree-shaking', 'Export all messages to allow tree shaking')
27
+ .preset(true)
28
+ )*/
24
29
  .addOption(new Option('--variable-notation <variableNotation>', 'What variable notation to use with exports')
25
30
  .choices(['camelCase','pascalCase','constantCase','snakeCase'])
26
- .default('camelCase')
31
+ .default('camelCase')
27
32
  )
28
33
  .addOption(new Option('--disable-minify', 'If disabled, all exported messages will have the same interface `(params) => ({value, attributes})`.')
29
34
  .preset(true)
package/index.js CHANGED
@@ -9,279 +9,417 @@ const exportDefault = `(id, params) => {
9
9
  }
10
10
  `
11
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[assignment].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}) => ({
12
+ const options = {
13
+ comments: true,
14
+ errorOnJunk: true,
15
+ includeMessages: [],
16
+ excludeMessages: [],
17
+ //treeShaking: false,
18
+ variableNotation: 'camelCase',
19
+ disableMinify: false, // TODO needs better name strictInterface?
20
+ useIsolating: false,
21
+ params: 'params',
22
+ exportDefault,
23
+ ...opts
24
+ }
25
+ if (!Array.isArray(options.locale)) options.locale = [options.locale]
26
+ if (!Array.isArray(options.includeMessages))
27
+ options.includeMessages = [options.includeMessages]
28
+ if (!Array.isArray(options.excludeMessages))
29
+ options.excludeMessages = [options.excludeMessages]
30
+
31
+ const metadata = {}
32
+ const exports = []
33
+ const functions = {} // global functions
34
+ let variable
35
+
36
+ const regexpValidVariable = /^[a-zA-Z]+[a-zA-Z0-9]*$/
37
+ const compileAssignment = (data) => {
38
+ variable = compileType(data)
39
+ metadata[variable] = {
40
+ id: data.name,
41
+ term: false,
42
+ params: false
43
+ }
44
+ return variable
45
+ }
46
+
47
+ const compileFunctionArguments = (data) => {
48
+ const positional = data.arguments?.positional.map((data) => {
49
+ return types[data.type](data)
50
+ })
51
+ const named = data.arguments?.named.reduce((obj, data) => {
52
+ // NamedArgument
53
+ const key = data.name.name
54
+ const value = compileType(data.value, data.type)
55
+ obj[key] = value
56
+ return obj
57
+ }, {})
58
+ return { positional, named }
59
+ }
60
+
61
+ const compileType = (data, parent) => {
62
+ try {
63
+ return types[data.type](data, parent)
64
+ } catch (e) {
65
+ console.error('Error:', e.message, data, e.stack)
66
+ throw new Error(e.message, { cause: data, stack: e.stack })
67
+ }
68
+ }
69
+
70
+ const types = {
71
+ Identifier: (data) => {
72
+ const value = variableNotation[options.variableNotation](data.name)
73
+ // Check for reserved words - TODO add in rest
74
+ if (['const', 'default', 'enum', 'if'].includes(value)) {
75
+ return '_' + value
76
+ }
77
+ return value
78
+ },
79
+ Attribute: (data) => {
80
+ const key = compileType(data.id)
81
+ const value = compileType(data.value, data.type)
82
+ return ` ${key}: ${value}`
83
+ },
84
+ Pattern: (data, parent) => {
85
+ return (
86
+ '`' +
87
+ data.elements
88
+ .map((data) => {
89
+ return compileType(data, parent)
90
+ })
91
+ .join('') +
92
+ '`'
93
+ )
94
+ },
95
+ // resources
96
+ Term: (data) => {
97
+ const assignment = compileAssignment(data.id)
98
+ const templateStringLiteral = compileType(data.value)
99
+ metadata[assignment].term = true
100
+ if (metadata[assignment].params) {
101
+ return `const ${assignment} = (${options.params}) => ${templateStringLiteral}\n`
102
+ }
103
+ return `const ${assignment} = ${templateStringLiteral}\n`
104
+ },
105
+ Message: (data) => {
106
+ const assignment = compileAssignment(data.id)
107
+
108
+ if (
109
+ options.includeMessages.length &&
110
+ !options.includeMessages.includes(assignment)
111
+ ) {
112
+ return ''
113
+ }
114
+
115
+ if (
116
+ options.excludeMessages.length &&
117
+ options.excludeMessages.includes(assignment)
118
+ ) {
119
+ return ''
120
+ }
121
+
122
+ const templateStringLiteral =
123
+ data.value && compileType(data.value, data.type)
124
+ metadata[assignment].attributes = data.attributes.length
125
+ let attributes = {}
126
+ if (metadata[assignment].attributes) {
127
+ // use Object.create(null) ?
128
+ attributes = `{\n${data.attributes
129
+ .map((data) => {
130
+ return ' ' + compileType(data)
131
+ })
132
+ .join(',\n')}\n }`
133
+ }
134
+ //
135
+ let message = ''
136
+ if (!options.disableMinify) {
137
+ if (metadata[assignment].attributes) {
138
+ if (metadata[assignment].params) {
139
+ message = `(${options.params}) => ({
107
140
  value:${templateStringLiteral},
108
141
  attributes:${attributes}
109
142
  })\n`
110
- }
111
- return `export const ${assignment} = {
143
+ } else {
144
+ message = `{
112
145
  value: ${templateStringLiteral},
113
146
  attributes: ${attributes}
114
147
  }\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 : ''}) => ({
148
+ }
149
+ } else if (metadata[assignment].params) {
150
+ message = `(${options.params}) => ${templateStringLiteral}\n`
151
+ } else {
152
+ message = `${templateStringLiteral}\n`
153
+ }
154
+ } else {
155
+ // consistent API
156
+ message = `(${metadata[assignment].params ? options.params : ''}) => ({
123
157
  value:${templateStringLiteral},
124
158
  attributes:${attributes}
125
159
  })\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
- metadata[variable].params ||= metadata[termName].params
189
-
190
- let params
191
- if (metadata[termName].params) {
192
- let {named} = compileFunctionArguments(data)
193
- named = JSON.stringify(named)
194
- if (named) {
195
- params = `{ ...${options.params}, ${named.substring(1, named.length-1)} }`
160
+ }
161
+ //if (options.treeShaking) {
162
+ if (assignment === metadata[assignment].id) {
163
+ exports.push(`${assignment}`)
164
+ } else {
165
+ exports.push(`'${metadata[assignment].id}': ${assignment}`)
166
+ }
167
+ return `export const ${assignment} = ${message}`
168
+ /*} else {
169
+ if (assignment === metadata[assignment].id) {
170
+ exports.push(`${assignment}: ${message}`)
196
171
  } else {
197
- params = options.params
198
- }
199
- }
200
- if (!options.disableMinify) {
201
- if (metadata[termName].params) {
202
- return `${termName}(${params})`
172
+ exports.push(`'${metadata[assignment].id}': ${message}`)
203
173
  }
204
- return `${termName}`
205
- }
206
- return `${termName}(${params ? params : ''})`
207
- },
208
- NamedArgument:(data) => {
209
- // Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
210
- const key = data.name.name // Don't transform value
211
- const value = compileType(data.value, data.type)
212
- return `${key}: ${value}`
213
- },
214
- SelectExpression: (data) => {
215
- functions.__select = true
216
- metadata[variable].params = true
217
- const value = compileType(data.selector)
218
- //const options = data.selector
219
- let fallback
220
- return `__select(\n ${value},\n {\n${data.variants.filter(data => {
221
- if (data.default) {
222
- fallback = compileType(data.value, data.type)
223
- }
224
- return !data.default
225
- }).map(data => {
226
- return ' '+compileType(data)
227
- }).join(',\n')}\n },\n ${fallback}\n )`
228
- },
229
- Variant: (data, parent) => {
230
- // Inconsistent: `Variant` uses `key` instead of `id` for Identifier
231
- const key = compileType(data.key)
232
- const value = compileType(data.value, data.type)
233
- return ` '${key}': ${value}`
234
- },
235
- FunctionReference: (data) => {
236
- return `${types[data.id.name](compileFunctionArguments(data))}`
237
- },
238
- // Functions
239
- DATETIME: (data) => {
240
- functions.__formatDateTime = true
241
- const {positional, named} = data
242
- const value = positional.shift()
243
- return `__formatDateTime(${value}, ${JSON.stringify(named)})`
244
- },
245
- NUMBER: (data) => {
246
- functions.__formatNumber = true
247
- const {positional, named} = data
248
- const value = positional.shift()
249
- return `__formatNumber(${value}, ${JSON.stringify(named)})`
250
- },
174
+ }*/
175
+ return ''
176
+ },
177
+ Comment: (data) => {
178
+ if (options.comments) return `// # ${data.content}\n`
179
+ return ''
180
+ },
181
+ GroupComment: (data) => {
182
+ if (options.comments) return `// ## ${data.content}\n`
183
+ return ''
184
+ },
185
+ ResourceComment: (data) => {
186
+ if (options.comments) return `// ### ${data.content}\n`
187
+ return ''
188
+ },
189
+ Junk: (data) => {
190
+ if (options.errorOnJunk) {
191
+ throw new Error('Junk found', { cause: data })
192
+ }
193
+ console.error('Error: Skipping Junk', JSON.stringify(data, null, 2))
194
+ return ''
195
+ },
196
+ // Element
197
+ TextElement: (data) => {
198
+ return data.value
199
+ },
200
+ Placeable: (data, parent) => {
201
+ return `${options.useIsolating ? '\u2068' : ''}\${${compileType(
202
+ data.expression,
203
+ parent
204
+ )}}${options.useIsolating ? '\u2069' : ''}`
205
+ },
206
+ // Expression
207
+ StringLiteral: (data, parent) => {
208
+ // JSON.stringify at parent level
209
+ if (['NamedArgument'].includes(parent)) {
210
+ return `${data.value}`
211
+ }
212
+ return `"${data.value}"`
213
+ },
214
+ NumberLiteral: (data) => {
215
+ const decimal = Number.parseFloat(data.value)
216
+ const number = Number.isInteger(decimal)
217
+ ? Number.parseInt(data.value)
218
+ : decimal
219
+ return Intl.NumberFormat(options.locale).format(number)
220
+ },
221
+ VariableReference: (data, parent) => {
222
+ functions.__formatVariable = true
223
+ metadata[variable].params = true
224
+ const value = `${options.params}?.${data.id.name}`
225
+ if (['Message', 'Variant', 'Attribute'].includes(parent)) {
226
+ return `__formatVariable(${value})`
227
+ }
228
+ return value
229
+ },
230
+ MessageReference: (data) => {
231
+ const messageName = compileType(data.id)
232
+ metadata[variable].params ||= metadata[messageName].params
233
+ if (!options.disableMinify) {
234
+ if (metadata[messageName].params) {
235
+ return `${messageName}(${options.params})`
236
+ }
237
+ return `${messageName}`
238
+ }
239
+ return `${messageName}(${
240
+ metadata[messageName].params ? options.params : ''
241
+ })`
242
+ },
243
+ TermReference: (data) => {
244
+ const termName = compileType(data.id)
245
+ metadata[variable].params ||= metadata[termName].params
246
+
247
+ let params
248
+ if (metadata[termName].params) {
249
+ let { named } = compileFunctionArguments(data)
250
+ named = JSON.stringify(named)
251
+ if (named) {
252
+ params = `{ ...${options.params}, ${named.substring(
253
+ 1,
254
+ named.length - 1
255
+ )} }`
256
+ } else {
257
+ params = options.params
258
+ }
259
+ }
260
+ if (!options.disableMinify) {
261
+ if (metadata[termName].params) {
262
+ return `${termName}(${params})`
263
+ }
264
+ return `${termName}`
265
+ }
266
+ return `${termName}(${params ? params : ''})`
267
+ },
268
+ NamedArgument: (data) => {
269
+ // Inconsistent: `NamedArgument` uses `name` instead of `id` for Identifier
270
+ const key = data.name.name // Don't transform value
271
+ const value = compileType(data.value, data.type)
272
+ return `${key}: ${value}`
273
+ },
274
+ SelectExpression: (data) => {
275
+ functions.__select = true
276
+ metadata[variable].params = true
277
+ const value = compileType(data.selector)
278
+ //const options = data.selector
279
+ let fallback
280
+ return `__select(\n ${value},\n {\n${data.variants
281
+ .filter((data) => {
282
+ if (data.default) {
283
+ fallback = compileType(data.value, data.type)
284
+ }
285
+ return !data.default
286
+ })
287
+ .map((data) => {
288
+ return ' ' + compileType(data)
289
+ })
290
+ .join(',\n')}\n },\n ${fallback}\n )`
291
+ },
292
+ Variant: (data, parent) => {
293
+ // Inconsistent: `Variant` uses `key` instead of `id` for Identifier
294
+ const key = compileType(data.key)
295
+ const value = compileType(data.value, data.type)
296
+ return ` '${key}': ${value}`
297
+ },
298
+ FunctionReference: (data) => {
299
+ return `${types[data.id.name](compileFunctionArguments(data))}`
300
+ },
301
+ // Functions
302
+ DATETIME: (data) => {
303
+ functions.__formatDateTime = true
304
+ const { positional, named } = data
305
+ const value = positional.shift()
306
+ return `__formatDateTime(${value}, ${JSON.stringify(named)})`
307
+ },
308
+ RELATIVETIME: (data) => {
309
+ functions.__formatRelativeTime = true
310
+ const { positional, named } = data
311
+ const value = positional.shift()
312
+ return `__formatRelativeTime(${value}, ${JSON.stringify(named)})`
313
+ },
314
+ NUMBER: (data) => {
315
+ functions.__formatNumber = true
316
+ const { positional, named } = data
317
+ const value = positional.shift()
318
+ return `__formatNumber(${value}, ${JSON.stringify(named)})`
319
+ }
320
+ }
321
+
322
+ if (/\t/.test(src)) {
323
+ console.error(
324
+ 'Source file contains tab characters (\t), replacing with <space>x4'
325
+ )
326
+ src = src.replace(/\t/g, ' ')
327
+ }
328
+
329
+ const { body } = parse(src)
330
+ let translations = ``
331
+ for (const data of body) {
332
+ translations += compileType(data)
333
+ }
334
+
335
+ let output = ``
336
+ if (
337
+ functions.__formatVariable ||
338
+ functions.__formatDateTime ||
339
+ functions.__formatNumber
340
+ ) {
341
+ output += `const __locales = ${JSON.stringify(opts.locale)}\n`
342
+ }
343
+ /*
344
+ const relativeTimeFormat = new Intl.RelativeTimeFormat(lang, {
345
+ localeMatcher: 'best fit',
346
+ numeric: 'always',
347
+ style: 'long'
348
+ })
349
+
350
+ const formatTime = (value) => {
351
+ value = new Date(value)
352
+ if (isNaN(value.getTime())) return value
353
+ try {
354
+ const [duration, unit] = relativeTimeDiff(value)
355
+ return relativeTimeFormat.format(duration, unit)
356
+ } catch (e) {
357
+ return dateTimeFormat.format(value)
358
+ }
359
+ }
360
+ */
361
+ if (functions.__formatRelativeTime) {
362
+ output += `
363
+ const __relativeTimeDiff = (d) => {
364
+ const msPerMinute = 60 * 1000
365
+ const msPerHour = msPerMinute * 60
366
+ const msPerDay = msPerHour * 24
367
+ const msPerWeek = msPerDay * 7
368
+ const msPerMonth = msPerDay * 30
369
+ const msPerYear = msPerDay * 365.25
370
+ const elapsed = d - new Date()
371
+
372
+ if (Math.abs(elapsed) < msPerMinute) {
373
+ return [Math.round(elapsed / 1000), 'second']
251
374
  }
252
-
253
- if (/\t/.test(src)) {
254
- console.error('Source file contains tab characters (\t), replacing with <space>x4')
255
- src = src.replace(/\t/g, ' ')
375
+ if (Math.abs(elapsed) < msPerHour) {
376
+ return [Math.round(elapsed / msPerMinute), 'minute']
256
377
  }
257
-
258
- const {body} = parse(src)
259
- let translations = ``
260
- for(const data of body) {
261
- translations += compileType(data)
378
+ if (Math.abs(elapsed) < msPerDay) {
379
+ return [Math.round(elapsed / msPerHour), 'hour']
262
380
  }
263
-
264
- let output = ``
265
- if (functions.__formatVariable || functions.__formatDateTime || functions.__formatNumber) {
266
- output += `const __locales = ${JSON.stringify(opts.locale)}\n`
381
+ if (Math.abs(elapsed) < msPerWeek * 2) {
382
+ return [Math.round(elapsed / msPerDay), 'day']
267
383
  }
268
-
269
- if (functions.__formatDateTime) {
270
- output += `
384
+ if (Math.abs(elapsed) < msPerMonth) {
385
+ return [Math.round(elapsed / msPerWeek), 'week']
386
+ }
387
+ if (Math.abs(elapsed) < msPerYear) {
388
+ return [Math.round(elapsed / msPerMonth), 'month']
389
+ }
390
+ return [Math.round(elapsed / msPerYear), 'year']
391
+ }
392
+ const __formatRelativeTime = (value, options) => {
393
+ if (typeof value === 'string') value = new Date(value)
394
+ if (isNaN(value.getTime())) return value
395
+ try {
396
+ const [duration, unit] = relativeTimeDiff(value)
397
+ return relativeTimeFormat.format(duration, unit)
398
+ } catch (e) {
399
+ return dateTimeFormat.format(value)
400
+ }
401
+ return new Intl.DateTimeFormat(__locales, options).format(value)
402
+ }
403
+ `
404
+ }
405
+ if (functions.__formatDateTime) {
406
+ output += `
271
407
  const __formatDateTime = (value, options) => {
408
+ if (typeof value === 'string') value = new Date(value)
409
+ if (isNaN(value.getTime())) return value
272
410
  return new Intl.DateTimeFormat(__locales, options).format(value)
273
411
  }
274
412
  `
275
- }
276
- if (functions.__formatVariable || functions.__formatNumber) {
277
- output += `
413
+ }
414
+ if (functions.__formatVariable || functions.__formatNumber) {
415
+ output += `
278
416
  const __formatNumber = (value, options) => {
279
417
  return new Intl.NumberFormat(__locales, options).format(value)
280
418
  }
281
419
  `
282
- }
283
- if (functions.__formatVariable) {
284
- output += `
420
+ }
421
+ if (functions.__formatVariable) {
422
+ output += `
285
423
  const __formatVariable = (value) => {
286
424
  if (typeof value === 'string') return value
287
425
  const decimal = Number.parseFloat(value)
@@ -289,37 +427,28 @@ const __formatVariable = (value) => {
289
427
  return __formatNumber(number)
290
428
  }
291
429
  `
292
- }
293
- if (functions.__select) {
294
- output += `
430
+ }
431
+ if (functions.__select) {
432
+ output += `
295
433
  const __select = (value, cases, fallback, options) => {
296
434
  const pluralRules = new Intl.PluralRules(__locales, options)
297
435
  const rule = pluralRules.select(value)
298
436
  return cases[value] ?? cases[rule] ?? fallback
299
437
  }
300
438
  `
301
- }
302
- output += `\n`+translations
303
- output += `const __exports = {${Object.keys(metadata)
304
- .filter(key => !metadata[key].term)
305
- .map(key => {
306
- if (key === metadata[key].id) return key
307
- return `'${metadata[key].id}':${key}`
308
- })
309
- .join(', ')
310
- }}\n`
311
- output += `\nexport default ${options.exportDefault}`
312
-
313
- return output
314
- }
439
+ }
440
+ output += `\n` + translations
441
+ output += `const __exports = {\n ${exports.join(',\n ')}\n}`
442
+ output += `\nexport default ${options.exportDefault}`
315
443
 
444
+ return output
445
+ }
316
446
 
317
447
  const variableNotation = {
318
- camelCase,
319
- pascalCase,
320
- snakeCase,
321
- constantCase
448
+ camelCase,
449
+ pascalCase,
450
+ snakeCase,
451
+ constantCase
322
452
  }
323
- // for `export defaults`
324
453
 
325
- export default compile
454
+ export default compile
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "fluent-transpiler",
3
- "version": "0.0.1",
4
- "description": "Transpile Fluent (ftl) files into optomized, tree-shakable, JavaScript EcmaScript Modules (esm).",
3
+ "version": "0.1.0",
4
+ "description": "Transpile Fluent (ftl) files into optimized, tree-shakable, JavaScript EcmaScript Modules (esm).",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "bin": {
8
8
  "ftl": "cli.js"
9
9
  },
10
+ "files": [
11
+ "cli.js",
12
+ "index.js"
13
+ ],
10
14
  "scripts": {
11
15
  "test": "npm run test:cli && npm run test:unit",
12
16
  "test:cli": "./cli.js --locale en-CA --locale en --use-isolating --variable-notation camelCase test/files/index.ftl --output test/files/index.mjs",
@@ -33,7 +37,7 @@
33
37
  "bugs": {
34
38
  "url": "https://github.com/willfarrell/fluent-transpiler/issues"
35
39
  },
36
- "homepage": "https://middy.js.org",
40
+ "homepage": "https://github.com/willfarrell/fluent-transpiler",
37
41
  "dependencies": {
38
42
  "@fluent/syntax": "0.18.1",
39
43
  "change-case": "4.1.2",
@@ -1,123 +0,0 @@
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
-
19
- -term-with-var = {$number}
20
- termWithVariable = {-term-with-var}
21
-
22
- ### Message References
23
- messageValue = message: { replaceTerm }
24
- messageNestedParamValue = message: { replaceParam }
25
-
26
-
27
- ## Special Characters
28
- openingBrace = This message features an opening curly brace: {"{"}.
29
- closingBrace = This message features a closing curly brace: {"}"}.
30
-
31
- blankIsRemoved = This message starts with no blanks.
32
- blankIsPreserved = {" "}This message starts with 4 spaces.
33
-
34
- leadingBracket =
35
- This message has an opening square bracket
36
- at the beginning of the third line:
37
- {"["}.
38
-
39
- literalQuote = Text in "double quotes".
40
- # This is OK, but cryptic and hard to read and edit.
41
- literalEscapedQuote = Text in {"\""}double quotes{"\""}.
42
-
43
-
44
- privacyLabel = Privacy{"\u00A0"}Policy
45
-
46
- # The dash character is an EM DASH but depending on the font face,
47
- # it might look like an EN DASH.
48
- dash = It's a dash—or is it?
49
-
50
- dashUnicode = It's a dash{"\u2014"}or is it?
51
-
52
- emoji = 😂
53
- emojiUnicode = {"\u01F602"}
54
-
55
- ## Multiline Text
56
- singleLine = Text can be written in a single line.
57
-
58
- multiLine = Text can also span multiple lines
59
- as long as each new line is indented
60
- by at least one space.
61
-
62
- block =
63
- Sometimes it's more readable to format
64
- multiline text as a "block", which means
65
- starting it on a new line. All lines must
66
- be indented by at least one space.
67
-
68
- leadingBlankSpaces = This message's value starts with the word "This".
69
- leadingBlankLines =
70
-
71
-
72
- This message's value starts with the word "This".
73
- The blank lines under the identifier are ignored.
74
-
75
- blankLines =
76
-
77
- The blank line above this line is ignored.
78
- This is a second line of the value.
79
-
80
- The blank line above this line is preserved.
81
-
82
- multiLineIndent =
83
- This message has 4 spaces of indent
84
- on the second line of its value.
85
-
86
- ## Functions
87
- timeElapsed = Time elapsed: { NUMBER($number, maximumFractionDigits: 0) }s.
88
-
89
- todayIs = Today is { DATETIME($date) }
90
-
91
- ## Selectors
92
- selectorNumberCardinal =
93
- { $number ->
94
- [zero] There are {$number} (zero).
95
- [one] There are {$number} (one).
96
- *[other] There are { $number } (other).
97
- }
98
-
99
- selectorNumberOrdinal =
100
- { NUMBER($number, type: "ordinal") ->
101
- [-1] There are {$number} (-1).
102
- [zero] There are {$number} (zero).
103
- [one] There are {$number} (one).
104
- [two] There are {$number} (two).
105
- [3.0] There are {$number} (3).
106
- [few] There are {$number} (few).
107
- [many] There are {$number} (many).
108
- [toomany] There are {$number} (toomany).
109
- *[other] There are { $number } (other).
110
- }
111
-
112
- subSelector = {selectorNumberOrdinal}
113
-
114
- ## Attributes
115
- loginInput = Predefined value
116
- .placeholder = email@example.com
117
- .ariaLabel = Login input value
118
- .title = Type your login email
119
-
120
- attributeHowTo =
121
- To add an attribute to this messages, write
122
- {".attr = Value"} on a new line.
123
- .attr = An actual attribute (not part of the text value above)
@@ -1,125 +0,0 @@
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 = (params) => `Informacje o ${brandName({ ...params, "case":"locative" })}.`
37
- const termWithVar = (params) => `${params?.number}`
38
- export const termWithVariable = (params) => `${termWithVar(params)}`
39
- // ### Message References
40
- export const messageValue = `message: ${replaceTerm}`
41
- export const messageNestedParamValue = (params) => `message: ${replaceParam(params)}`
42
- // ## Special Characters
43
- export const openingBrace = `This message features an opening curly brace: ${"{"}.`
44
- export const closingBrace = `This message features a closing curly brace: ${"}"}.`
45
- export const blankIsRemoved = `This message starts with no blanks.`
46
- export const blankIsPreserved = `${" "}This message starts with 4 spaces.`
47
- export const leadingBracket = `This message has an opening square bracket
48
- at the beginning of the third line:
49
- ${"["}.`
50
- export const literalQuote = `Text in "double quotes".`
51
- export const literalEscapedQuote = `Text in ${"\""}double quotes${"\""}.`
52
- export const privacyLabel = `Privacy${"\u00A0"}Policy`
53
- export const dash = `It's a dash—or is it?`
54
- export const dashUnicode = `It's a dash${"\u2014"}or is it?`
55
- export const emoji = `😂`
56
- export const emojiUnicode = `${"\u01F602"}`
57
- // ## Multiline Text
58
- export const singleLine = `Text can be written in a single line.`
59
- export const multiLine = `Text can also span multiple lines
60
- as long as each new line is indented
61
- by at least one space.`
62
- export const block = `Sometimes it's more readable to format
63
- multiline text as a "block", which means
64
- starting it on a new line. All lines must
65
- be indented by at least one space.`
66
- export const leadingBlankSpaces = `This message's value starts with the word "This".`
67
- export const leadingBlankLines = `This message's value starts with the word "This".
68
- The blank lines under the identifier are ignored.`
69
- export const blankLines = `The blank line above this line is ignored.
70
- This is a second line of the value.
71
-
72
- The blank line above this line is preserved.`
73
- export const multiLineIndent = `This message has 4 spaces of indent
74
- on the second line of its value.`
75
- // ## Functions
76
- export const timeElapsed = (params) => `Time elapsed: ${__formatNumber(params?.number, {"maximumFractionDigits":"0"})}s.`
77
- export const todayIs = (params) => `Today is ${__formatDateTime(params?.date, {})}`
78
- // ## Selectors
79
- export const selectorNumberCardinal = (params) => `${__select(
80
- params?.number,
81
- {
82
- 'zero': `There are ${__formatVariable(params?.number)} (zero).`,
83
- 'one': `There are ${__formatVariable(params?.number)} (one).`
84
- },
85
- `There are ${__formatVariable(params?.number)} (other).`
86
- )}`
87
- export const selectorNumberOrdinal = (params) => `${__select(
88
- __formatNumber(params?.number, {"type":"ordinal"}),
89
- {
90
- '-1': `There are ${__formatVariable(params?.number)} (-1).`,
91
- 'zero': `There are ${__formatVariable(params?.number)} (zero).`,
92
- 'one': `There are ${__formatVariable(params?.number)} (one).`,
93
- 'two': `There are ${__formatVariable(params?.number)} (two).`,
94
- '3': `There are ${__formatVariable(params?.number)} (3).`,
95
- 'few': `There are ${__formatVariable(params?.number)} (few).`,
96
- 'many': `There are ${__formatVariable(params?.number)} (many).`,
97
- 'toomany': `There are ${__formatVariable(params?.number)} (toomany).`
98
- },
99
- `There are ${__formatVariable(params?.number)} (other).`
100
- )}`
101
- export const subSelector = (params) => `${selectorNumberOrdinal(params)}`
102
- // ## Attributes
103
- export const loginInput = {
104
- value: `Predefined value`,
105
- attributes: {
106
- placeholder: `email@example.com`,
107
- ariaLabel: `Login input value`,
108
- title: `Type your login email`
109
- }
110
- }
111
- export const attributeHowTo = {
112
- value: `To add an attribute to this messages, write
113
- ${".attr = Value"} on a new line.`,
114
- attributes: {
115
- attr: `An actual attribute (not part of the text value above)`
116
- }
117
- }
118
- const __exports = {text, replaceParam, replaceTerm, 'parameterized-terms':parameterizedTerms, termWithVariable, 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, subSelector, loginInput, attributeHowTo}
119
-
120
- export default (id, params) => {
121
- const source = __exports[id] ?? __exports['_'+id]
122
- if (typeof source === 'undefined') return '*** '+id+' ***'
123
- if (typeof source === 'function') return source(params)
124
- return source
125
- }
package/test/index.js DELETED
@@ -1,84 +0,0 @@
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
- }