@userfrosting/sprinkle-core 6.0.0-alpha.1 → 6.0.0-alpha.3

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.
@@ -0,0 +1,293 @@
1
+ /**
2
+ * Translator composable store.
3
+ *
4
+ * This pinia store is used to access the translator and to use the translator.
5
+ */
6
+ import { ref } from 'vue'
7
+ import { defineStore } from 'pinia'
8
+ import axios from 'axios'
9
+ import type { DictionaryEntries, DictionaryResponse, DictionaryConfig } from '../interfaces'
10
+ import { DateTime } from 'luxon'
11
+ import type { PluralRules } from './Helpers/PluralRules'
12
+ import {
13
+ rule0,
14
+ rule1,
15
+ rule2,
16
+ rule3,
17
+ rule4,
18
+ rule5,
19
+ rule6,
20
+ rule7,
21
+ rule8,
22
+ rule9,
23
+ rule10,
24
+ rule11,
25
+ rule12,
26
+ rule13,
27
+ rule14,
28
+ rule15
29
+ } from './Helpers/PluralRules'
30
+
31
+ // List all available plural rules
32
+ const rules: PluralRules = {
33
+ 0: rule0,
34
+ 1: rule1,
35
+ 2: rule2,
36
+ 3: rule3,
37
+ 4: rule4,
38
+ 5: rule5,
39
+ 6: rule6,
40
+ 7: rule7,
41
+ 8: rule8,
42
+ 9: rule9,
43
+ 10: rule10,
44
+ 11: rule11,
45
+ 12: rule12,
46
+ 13: rule13,
47
+ 14: rule14,
48
+ 15: rule15
49
+ }
50
+
51
+ export const useTranslator = defineStore(
52
+ 'translator',
53
+ () => {
54
+ /**
55
+ * Variables
56
+ */
57
+ const defaultPluralKey = 'plural'
58
+ const identifier = ref<string>('')
59
+ const dictionary = ref<DictionaryEntries>({})
60
+ const config = ref<DictionaryConfig>({
61
+ name: '',
62
+ regional: '',
63
+ authors: [],
64
+ plural_rule: 0,
65
+ dates: ''
66
+ })
67
+
68
+ /**
69
+ * Functions
70
+ */
71
+ // Load the dictionary from the API
72
+ async function load() {
73
+ axios.get<DictionaryResponse>('/api/dictionary').then((response) => {
74
+ identifier.value = response.data.identifier
75
+ config.value = response.data.config
76
+ dictionary.value = response.data.dictionary
77
+ })
78
+ }
79
+
80
+ // The translate function
81
+ function translate(key: string, placeholders: string | number | object = {}): string {
82
+ const { message, placeholders: mutatedPlaceholders } = getMessageFromKey(
83
+ key,
84
+ placeholders
85
+ )
86
+ placeholders = mutatedPlaceholders
87
+
88
+ return replacePlaceholders(message, placeholders)
89
+ }
90
+
91
+ /**
92
+ * Format a date to the user locale
93
+ *
94
+ * @param date The date to format, in ISO format
95
+ * @param format The format to use. Default to `DATETIME_MED_WITH_WEEKDAY`.
96
+ * See the Luxon documentation for more information on formatting
97
+ *
98
+ * @see https://moment.github.io/luxon/#/formatting?id=presets
99
+ * @see https://moment.github.io/luxon/#/formatting?id=table-of-tokens
100
+ */
101
+ function translateDate(
102
+ date: string,
103
+ format: string | object = DateTime.DATETIME_MED_WITH_WEEKDAY
104
+ ): string {
105
+ const dt = getDateTime(date)
106
+ if (typeof format === 'object') {
107
+ return dt.toLocaleString(format)
108
+ } else {
109
+ return dt.toFormat(format)
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Returns the Luxon DateTime object for the given date, with the user
115
+ * locale, so Luxon methods can be used without having to set the locale
116
+ *
117
+ * @param date The date to format, in ISO format
118
+ */
119
+ function getDateTime(date: string): DateTime {
120
+ return DateTime.fromISO(date).setLocale(config.value.dates)
121
+ }
122
+
123
+ // TODO : Add doc + make Placeholders a type
124
+ function getMessageFromKey(
125
+ key: string,
126
+ placeholders: string | number | Record<string, any>
127
+ ): { message: string; placeholders: string | number | Record<string, any> } {
128
+ // Return direct match
129
+ if (dictionary.value[key] !== undefined) {
130
+ return { message: dictionary.value[key], placeholders }
131
+ }
132
+
133
+ // First, let's see if we can get the plural rules.
134
+ // A plural form will always have priority over the `@TRANSLATION` instruction
135
+ // We start by picking up the plural key, aka which placeholder contains the numeric value defining how many {x} we have
136
+ const pluralKey = dictionary.value[key + '.@PLURAL'] || defaultPluralKey
137
+
138
+ // Let's get the plural value, aka how many {x} we have
139
+ // If no plural value was found, we fallback to `@TRANSLATION` instruction or default to 1 as a last resort
140
+ let pluralValue: number = 1
141
+ if (typeof placeholders === 'object' && placeholders[pluralKey] !== undefined) {
142
+ pluralValue = Number(placeholders[pluralKey])
143
+ } else if (typeof placeholders === 'number' || typeof placeholders === 'string') {
144
+ pluralValue = Number(placeholders)
145
+ } else if (dictionary.value[key + '.@TRANSLATION'] !== undefined) {
146
+ // We have a `@TRANSLATION` instruction, return this
147
+ return { message: dictionary.value[key + '.@TRANSLATION'], placeholders }
148
+ }
149
+
150
+ // If placeholders is a numeric value, we transform back to an array for replacement in the main message
151
+ if (typeof placeholders === 'number' || typeof placeholders === 'string') {
152
+ placeholders = { [pluralKey]: pluralValue }
153
+ } else if (typeof placeholders === 'object' && placeholders[pluralKey] === undefined) {
154
+ placeholders = { ...placeholders, [pluralKey]: pluralValue }
155
+ }
156
+
157
+ // At this point, we need to go deeper and find the correct plural form to use
158
+ const pluralRuleKey = getPluralMessageKey(key, pluralValue)
159
+
160
+ // Only return if the plural is not null. Will happen if the message array don't follow the rules
161
+ if (dictionary.value[key + '.' + pluralRuleKey] !== undefined) {
162
+ return { message: dictionary.value[key + '.' + pluralRuleKey], placeholders }
163
+ }
164
+
165
+ // One last check... If we don't have a rule, but the $pluralValue
166
+ // as a key does exist, we might still be able to return it
167
+ if (dictionary.value[key + '.' + pluralValue] !== undefined) {
168
+ return { message: dictionary.value[key + '.' + pluralValue], placeholders }
169
+ }
170
+
171
+ // Return @TRANSLATION match
172
+ if (dictionary.value[key + '.@TRANSLATION'] !== undefined) {
173
+ return { message: dictionary.value[key + '.@TRANSLATION'], placeholders }
174
+ }
175
+
176
+ // If the message is an array, but we can't find a plural form or a "@TRANSLATION" instruction, we can't go further.
177
+ // We can't return the array, so we'll return the key
178
+ return { message: key, placeholders }
179
+ }
180
+
181
+ function replacePlaceholders(
182
+ message: string,
183
+ placeholders: string | number | Record<string, any>
184
+ ): string {
185
+ // If placeholders is not an object at this point, we make it an object, using `plural` as the key
186
+ if (typeof placeholders !== 'object') {
187
+ placeholders = { [defaultPluralKey]: placeholders }
188
+ }
189
+
190
+ // Interpolate translatable placeholders values. This allows to
191
+ // pre-translate placeholder which value starts with the `&` character
192
+ // console.debug('Looping Placeholders', placeholders)
193
+ for (const [name, value] of Object.entries(placeholders)) {
194
+ // console.debug(`> ${name}: ${value}`)
195
+
196
+ //We don't allow nested placeholders. They will return errors on the next lines
197
+ if (typeof value !== 'string') {
198
+ continue
199
+ }
200
+
201
+ // We test if the placeholder value starts the "&" character.
202
+ // That means we need to translate that placeholder value
203
+ if (value.startsWith('&')) {
204
+ // Remove the current placeholder from the master $placeholder
205
+ // array, otherwise we end up in an infinite loop
206
+ const data = Object.fromEntries(
207
+ Object.entries(placeholders).filter(([k]) => k !== name)
208
+ )
209
+
210
+ // Translate placeholders value and place it in the main $placeholder array
211
+ placeholders[name] = translate(value.substring(1), data)
212
+ }
213
+ }
214
+
215
+ // We check for {{&...}} strings in the resulting message.
216
+ // While the previous loop pre-translated placeholder value, this one
217
+ // pre-translate the message string vars
218
+ // We use some regex magic to detect them !
219
+ message = message.replace(/{{&(([^}]+[^a-z]))}}/g, (match, p1) => {
220
+ return translate(p1, placeholders)
221
+ })
222
+
223
+ // Now it's time to replace the remaining placeholder.
224
+ for (const [name, value] of Object.entries(placeholders)) {
225
+ const regex = new RegExp(`{{${name}}}`, 'g')
226
+ message = message.replace(regex, String(value))
227
+ }
228
+
229
+ return message
230
+ }
231
+
232
+ /**
233
+ * Return the correct plural message form to use.
234
+ * When multiple plural form are available for a message, this method will return the correct oen to use based on the numeric value.
235
+ *
236
+ * @param int $pluralValue The numeric value used to select the correct message
237
+ *
238
+ * @return int|null Returns which key from $messageArray to use
239
+ */
240
+ function getPluralMessageKey(key: string, pluralValue: number): number | null {
241
+ // Bypass the rules for a value of "0". Instead of returning the
242
+ // correct plural form (>= 1), we force return the "0" form, which
243
+ // can used to display "0 users" as "No users".
244
+ if (pluralValue === 0 && dictionary.value[key + '.0'] !== undefined) {
245
+ return 0
246
+ }
247
+
248
+ // Get the correct plural form to use depending on the language
249
+ const pluralForm = getPluralForm(pluralValue)
250
+
251
+ // If the dictionary contains a string for this form, return the form
252
+ if (dictionary.value[key + '.' + pluralForm] !== undefined) {
253
+ return pluralForm
254
+ }
255
+
256
+ // If the key we need doesn't exist, use the previous available one, including the special "0" form
257
+ // This is a fallback to avoid errors when the dictionary is not complete
258
+ for (let i = pluralForm; i >= 0; i--) {
259
+ if (dictionary.value[key + '.' + i] !== undefined) {
260
+ return i
261
+ }
262
+ }
263
+
264
+ // If no key was found, null will be returned
265
+ return null
266
+ }
267
+
268
+ function getPluralForm(pluralValue: number, forceRule?: number): number {
269
+ const rule = forceRule ?? config.value.plural_rule
270
+
271
+ if (rule < 0 || rule >= Object.keys(rules).length) {
272
+ throw new Error(`The rule number ${rule} must be between 0 and 15`)
273
+ }
274
+
275
+ return rules[rule](pluralValue)
276
+ }
277
+
278
+ /**
279
+ * Return store
280
+ */
281
+ return {
282
+ dictionary,
283
+ load,
284
+ config,
285
+ identifier,
286
+ translate,
287
+ translateDate,
288
+ getPluralForm,
289
+ getDateTime
290
+ }
291
+ },
292
+ { persist: true }
293
+ )
@@ -1,17 +1,26 @@
1
1
  import { describe, expect, test, vi } from 'vitest'
2
+ import { createApp } from 'vue'
2
3
  import { useConfigStore } from '../stores/config'
3
4
  import plugin from '..'
4
5
  import * as Config from '../stores/config'
6
+ import * as Translator from '../stores/useTranslator'
5
7
 
6
8
  const mockConfigStore = {
7
9
  load: vi.fn()
8
10
  }
9
11
 
12
+ const mockTranslatorStore = {
13
+ load: vi.fn()
14
+ }
15
+
10
16
  describe('Plugin', () => {
11
17
  test('should install the plugin and initiate load', () => {
18
+ const app = createApp({})
19
+
12
20
  vi.spyOn(Config, 'useConfigStore').mockReturnValue(mockConfigStore as any)
21
+ vi.spyOn(Translator, 'useTranslator').mockReturnValue(mockTranslatorStore as any)
13
22
 
14
- plugin.install()
23
+ plugin.install(app)
15
24
 
16
25
  expect(useConfigStore).toHaveBeenCalled()
17
26
  expect(mockConfigStore.load).toHaveBeenCalled()