@userfrosting/sprinkle-core 6.0.0-alpha.2 → 6.0.0-alpha.4
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/app/assets/composables/usePageMeta.ts +4 -2
- package/app/assets/index.d.ts +8 -0
- package/app/assets/index.ts +22 -4
- package/app/assets/interfaces/DictionaryApi.ts +25 -0
- package/app/assets/interfaces/index.ts +1 -0
- package/app/assets/routes/index.ts +44 -0
- package/app/assets/stores/Helpers/PluralRules.ts +218 -0
- package/app/assets/stores/index.ts +1 -0
- package/app/assets/stores/useTranslator.ts +293 -0
- package/app/assets/tests/plugin.test.ts +10 -1
- package/app/assets/tests/stores/Helpers/PluralRules.test.ts +440 -0
- package/app/assets/tests/stores/useTranslator.test.ts +373 -0
- package/app/assets/views/401Unauthorized.vue +3 -0
- package/app/assets/views/403Forbidden.vue +3 -0
- package/app/assets/views/404NotFound.vue +3 -0
- package/app/assets/views/ErrorPage.vue +3 -0
- package/package.json +6 -3
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { computed, ref, watch } from 'vue'
|
|
2
2
|
import { useRoute } from 'vue-router'
|
|
3
|
+
import { useTranslator } from '@userfrosting/sprinkle-core/stores'
|
|
3
4
|
import { useConfigStore } from '../stores'
|
|
4
5
|
import { defineStore } from 'pinia'
|
|
5
6
|
|
|
@@ -17,6 +18,7 @@ export const usePageMeta = defineStore('pageMeta', () => {
|
|
|
17
18
|
* Globally provided properties
|
|
18
19
|
*/
|
|
19
20
|
const route = useRoute()
|
|
21
|
+
const { translate } = useTranslator()
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* States
|
|
@@ -68,7 +70,7 @@ export const usePageMeta = defineStore('pageMeta', () => {
|
|
|
68
70
|
|
|
69
71
|
// Update Page Title & Description with current route
|
|
70
72
|
title.value = route.meta.title || ''
|
|
71
|
-
description.value = route.meta.description || ''
|
|
73
|
+
description.value = translate(route.meta.description || '')
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
// Update the document title
|
|
@@ -90,7 +92,7 @@ export const usePageMeta = defineStore('pageMeta', () => {
|
|
|
90
92
|
*/
|
|
91
93
|
const siteTitle = computed<string>(() => useConfigStore().get('site.title') || '')
|
|
92
94
|
const pageFullTitle = computed<string>(() => {
|
|
93
|
-
return title.value ? title.value + ' | ' + siteTitle.value : siteTitle.value
|
|
95
|
+
return title.value ? translate(title.value) + ' | ' + siteTitle.value : siteTitle.value
|
|
94
96
|
})
|
|
95
97
|
|
|
96
98
|
/**
|
package/app/assets/index.ts
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { App } from 'vue'
|
|
2
|
+
import { useConfigStore, useTranslator } from './stores'
|
|
2
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Core Sprinkle initialization recipe.
|
|
6
|
+
*
|
|
7
|
+
* This recipe is responsible for loading the configuration from the api,
|
|
8
|
+
* loading the translations and register the translator as $t and $tdate global
|
|
9
|
+
* properties.
|
|
10
|
+
*/
|
|
3
11
|
export default {
|
|
4
|
-
install: () => {
|
|
5
|
-
|
|
6
|
-
|
|
12
|
+
install: (app: App) => {
|
|
13
|
+
/**
|
|
14
|
+
* Load configuration
|
|
15
|
+
*/
|
|
16
|
+
useConfigStore().load()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Load translations & add $t+$tdate to global properties
|
|
20
|
+
*/
|
|
21
|
+
const translator = useTranslator()
|
|
22
|
+
translator.load()
|
|
23
|
+
app.config.globalProperties.$t = translator.translate
|
|
24
|
+
app.config.globalProperties.$tdate = translator.translateDate
|
|
7
25
|
}
|
|
8
26
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Api Interface - What the API expects and what it returns
|
|
3
|
+
*
|
|
4
|
+
* This interface is tied to the `DictionaryController` API, accessed at the
|
|
5
|
+
* GET `/api/dictionary` endpoint.
|
|
6
|
+
*
|
|
7
|
+
* This api doesn't have a corresponding Request data interface.
|
|
8
|
+
*/
|
|
9
|
+
export interface DictionaryResponse {
|
|
10
|
+
identifier: string
|
|
11
|
+
config: DictionaryConfig
|
|
12
|
+
dictionary: DictionaryEntries
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DictionaryEntries {
|
|
16
|
+
[key: string]: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface DictionaryConfig {
|
|
20
|
+
name: string
|
|
21
|
+
regional: string
|
|
22
|
+
authors: string[]
|
|
23
|
+
plural_rule: number
|
|
24
|
+
dates: string
|
|
25
|
+
}
|
|
@@ -24,6 +24,7 @@ export type { AssociativeArray } from './common'
|
|
|
24
24
|
export { Severity } from './severity'
|
|
25
25
|
export type { Sprunjer, SprunjerData, SprunjerListable, SprunjerListableOption } from './sprunjer'
|
|
26
26
|
export type { SprunjerRequest, SprunjerResponse } from './sprunjerApi'
|
|
27
|
+
export type { DictionaryResponse, DictionaryEntries, DictionaryConfig } from './DictionaryApi'
|
|
27
28
|
|
|
28
29
|
// Misc
|
|
29
30
|
export type { ApiResponse } from './ApiResponse'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default error routes.
|
|
3
|
+
*
|
|
4
|
+
* N.B.: The first of these routes serve as a catch-all, so 404 not found must
|
|
5
|
+
* be first.
|
|
6
|
+
*/
|
|
7
|
+
export default [
|
|
8
|
+
{
|
|
9
|
+
path: '/:pathMatch(.*)*',
|
|
10
|
+
name: 'NotFound',
|
|
11
|
+
meta: {
|
|
12
|
+
title: 'ERROR.404.TITLE',
|
|
13
|
+
description: 'ERROR.404.DESCRIPTION'
|
|
14
|
+
},
|
|
15
|
+
component: () => import('../views/404NotFound.vue')
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
path: '/:pathMatch(.*)*',
|
|
19
|
+
name: 'Unauthorized',
|
|
20
|
+
meta: {
|
|
21
|
+
title: 'ERROR.401.TITLE',
|
|
22
|
+
description: 'ERROR.401.DESCRIPTION'
|
|
23
|
+
},
|
|
24
|
+
component: () => import('../views/401Unauthorized.vue')
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
path: '/:pathMatch(.*)*',
|
|
28
|
+
name: 'Forbidden',
|
|
29
|
+
meta: {
|
|
30
|
+
title: 'ERROR.403.TITLE',
|
|
31
|
+
description: 'ERROR.403.DESCRIPTION'
|
|
32
|
+
},
|
|
33
|
+
component: () => import('../views/403Forbidden.vue')
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
path: '/:pathMatch(.*)*',
|
|
37
|
+
name: 'Error',
|
|
38
|
+
meta: {
|
|
39
|
+
title: 'ERROR.TITLE',
|
|
40
|
+
description: 'ERROR.DESCRIPTION'
|
|
41
|
+
},
|
|
42
|
+
component: () => import('../views/ErrorPage.vue')
|
|
43
|
+
}
|
|
44
|
+
]
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Families: Asian (Chinese, Japanese, Korean, Vietnamese), Persian, Turkic/Altaic (Turkish), Thai, Lao
|
|
3
|
+
* 1 - everything: 0, 1, 2, ...
|
|
4
|
+
*/
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
6
|
+
export const rule0 = (number: number): number => {
|
|
7
|
+
return 1
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Families: Germanic (Danish, Dutch, English, Faroese, Frisian, German, Norwegian, Swedish),
|
|
12
|
+
* Finno-Ugric (Estonian, Finnish, Hungarian), Language isolate (Basque),
|
|
13
|
+
* Latin/Greek (Greek), Semitic (Hebrew), Romanic (Italian, Portuguese, Spanish, Catalan)
|
|
14
|
+
* 1 - 1
|
|
15
|
+
* 2 - everything else: 0, 2, 3, ...
|
|
16
|
+
*/
|
|
17
|
+
export const rule1 = (number: number): number => {
|
|
18
|
+
return number === 1 ? 1 : 2
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Families: Romanic (French, Brazilian Portuguese)
|
|
23
|
+
* 1 - 0, 1
|
|
24
|
+
* 2 - everything else: 2, 3, ...
|
|
25
|
+
*/
|
|
26
|
+
export const rule2 = (number: number): number => {
|
|
27
|
+
return number === 0 || number === 1 ? 1 : 2
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Families: Baltic (Latvian)
|
|
32
|
+
* 1 - 0
|
|
33
|
+
* 2 - ends in 1, not 11: 1, 21, ... 101, 121, ...
|
|
34
|
+
* 3 - everything else: 2, 3, ... 10, 11, 12, ... 20, 22, ...
|
|
35
|
+
*/
|
|
36
|
+
export const rule3 = (number: number): number => {
|
|
37
|
+
return number === 0 ? 1 : number % 10 == 1 && number % 100 != 11 ? 2 : 3
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Families: Celtic (Scottish Gaelic)
|
|
42
|
+
* 1 - is 1 or 11: 1, 11
|
|
43
|
+
* 2 - is 2 or 12: 2, 12
|
|
44
|
+
* 3 - others between 3 and 19: 3, 4, ... 10, 13, ... 18, 19
|
|
45
|
+
* 4 - everything else: 0, 20, 21, ...
|
|
46
|
+
*/
|
|
47
|
+
export const rule4 = (number: number): number => {
|
|
48
|
+
return number === 1 || number == 11
|
|
49
|
+
? 1
|
|
50
|
+
: number === 2 || number === 12
|
|
51
|
+
? 2
|
|
52
|
+
: number >= 3 && number <= 19
|
|
53
|
+
? 3
|
|
54
|
+
: 4
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Families: Romanic (Romanian)
|
|
59
|
+
* 1 - 1
|
|
60
|
+
* 2 - is 0 or ends in 01-19: 0, 2, 3, ... 19, 101, 102, ... 119, 201, ...
|
|
61
|
+
* 3 - everything else: 20, 21, ...
|
|
62
|
+
*/
|
|
63
|
+
export const rule5 = (number: number): number => {
|
|
64
|
+
return number === 1 ? 1 : number === 0 || (number % 100 > 0 && number % 100 < 20) ? 2 : 3
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Families: Baltic (Lithuanian)
|
|
69
|
+
* 1 - ends in 1, not 11: 1, 21, 31, ... 101, 121, ...
|
|
70
|
+
* 2 - ends in 0 or ends in 10-20: 0, 10, 11, 12, ... 19, 20, 30, 40, ...
|
|
71
|
+
* 3 - everything else: 2, 3, ... 8, 9, 22, 23, ... 29, 32, 33, ...
|
|
72
|
+
*/
|
|
73
|
+
export const rule6 = (number: number): number => {
|
|
74
|
+
return number % 10 === 1 && number % 100 !== 11
|
|
75
|
+
? 1
|
|
76
|
+
: number % 10 < 2 || (number % 100 >= 10 && number % 100 < 20)
|
|
77
|
+
? 2
|
|
78
|
+
: 3
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Families: Slavic (Croatian, Serbian, Russian, Ukrainian)
|
|
83
|
+
* 1 - ends in 1, not 11: 1, 21, 31, ... 101, 121, ...
|
|
84
|
+
* 2 - ends in 2-4, not 12-14: 2, 3, 4, 22, 23, 24, 32, ...
|
|
85
|
+
* 3 - everything else: 0, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 25, 26, ...
|
|
86
|
+
*/
|
|
87
|
+
export const rule7 = (number: number): number => {
|
|
88
|
+
return number % 10 === 1 && number % 100 !== 11
|
|
89
|
+
? 1
|
|
90
|
+
: number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 10 || number % 100 >= 20)
|
|
91
|
+
? 2
|
|
92
|
+
: 3
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Families: Slavic (Slovak, Czech)
|
|
97
|
+
* 1 - 1
|
|
98
|
+
* 2 - 2, 3, 4
|
|
99
|
+
* 3 - everything else: 0, 5, 6, 7, ...
|
|
100
|
+
*/
|
|
101
|
+
export const rule8 = (number: number): number => {
|
|
102
|
+
return number === 1 ? 1 : number >= 2 && number <= 4 ? 2 : 3
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Families: Slavic (Polish)
|
|
107
|
+
* 1 - 1
|
|
108
|
+
* 2 - ends in 2-4, not 12-14: 2, 3, 4, 22, 23, 24, 32, ... 104, 122, ...
|
|
109
|
+
* 3 - everything else: 0, 5, 6, ... 11, 12, 13, 14, 15, ... 20, 21, 25, ...
|
|
110
|
+
*/
|
|
111
|
+
export const rule9 = (number: number): number => {
|
|
112
|
+
return number === 1
|
|
113
|
+
? 1
|
|
114
|
+
: number % 10 >= 2 && number % 10 <= 4 && (number % 100 < 12 || number % 100 > 14)
|
|
115
|
+
? 2
|
|
116
|
+
: 3
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Families: Slavic (Slovenian, Sorbian)
|
|
121
|
+
* 1 - ends in 01: 1, 101, 201, ...
|
|
122
|
+
* 2 - ends in 02: 2, 102, 202, ...
|
|
123
|
+
* 3 - ends in 03-04: 3, 4, 103, 104, 203, 204, ...
|
|
124
|
+
* 4 - everything else: 0, 5, 6, 7, 8, 9, 10, 11, ...
|
|
125
|
+
*/
|
|
126
|
+
export const rule10 = (number: number): number => {
|
|
127
|
+
return number % 100 === 1
|
|
128
|
+
? 1
|
|
129
|
+
: number % 100 === 2
|
|
130
|
+
? 2
|
|
131
|
+
: number % 100 === 3 || number % 100 === 4
|
|
132
|
+
? 3
|
|
133
|
+
: 4
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Families: Celtic (Irish Gaeilge)
|
|
138
|
+
* 1 - 1
|
|
139
|
+
* 2 - 2
|
|
140
|
+
* 3 - is 3-6: 3, 4, 5, 6
|
|
141
|
+
* 4 - is 7-10: 7, 8, 9, 10
|
|
142
|
+
* 5 - everything else: 0, 11, 12, ...
|
|
143
|
+
*/
|
|
144
|
+
export const rule11 = (number: number): number => {
|
|
145
|
+
return number === 1
|
|
146
|
+
? 1
|
|
147
|
+
: number === 2
|
|
148
|
+
? 2
|
|
149
|
+
: number >= 3 && number <= 6
|
|
150
|
+
? 3
|
|
151
|
+
: number >= 7 && number <= 10
|
|
152
|
+
? 4
|
|
153
|
+
: 5
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Families: Semitic (Arabic).
|
|
158
|
+
*
|
|
159
|
+
* 1 - 1
|
|
160
|
+
* 2 - 2
|
|
161
|
+
* 3 - ends in 03-10: 3, 4, ... 10, 103, 104, ... 110, 203, 204, ...
|
|
162
|
+
* 4 - ends in 11-99: 11, ... 99, 111, 112, ...
|
|
163
|
+
* 5 - everything else: 100, 101, 102, 200, 201, 202, ...
|
|
164
|
+
* 6 - 0
|
|
165
|
+
*/
|
|
166
|
+
export const rule12 = (number: number): number => {
|
|
167
|
+
return number === 1
|
|
168
|
+
? 1
|
|
169
|
+
: number === 2
|
|
170
|
+
? 2
|
|
171
|
+
: number % 100 >= 3 && number % 100 <= 10
|
|
172
|
+
? 3
|
|
173
|
+
: number % 100 >= 11
|
|
174
|
+
? 4
|
|
175
|
+
: number != 0
|
|
176
|
+
? 5
|
|
177
|
+
: 6
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Families: Semitic (Maltese)
|
|
182
|
+
* 1 - 1
|
|
183
|
+
* 2 - is 0 or ends in 01-10: 0, 2, 3, ... 9, 10, 101, 102, ...
|
|
184
|
+
* 3 - ends in 11-19: 11, 12, ... 18, 19, 111, 112, ...
|
|
185
|
+
* 4 - everything else: 20, 21, ...
|
|
186
|
+
*/
|
|
187
|
+
export const rule13 = (number: number): number => {
|
|
188
|
+
return number === 1
|
|
189
|
+
? 1
|
|
190
|
+
: number === 0 || (number % 100 >= 1 && number % 100 < 11)
|
|
191
|
+
? 2
|
|
192
|
+
: number % 100 > 10 && number % 100 < 20
|
|
193
|
+
? 3
|
|
194
|
+
: 4
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Families: Slavic (Macedonian)
|
|
199
|
+
* 1 - ends in 1: 1, 11, 21, ...
|
|
200
|
+
* 2 - ends in 2: 2, 12, 22, ...
|
|
201
|
+
* 3 - everything else: 0, 3, 4, ... 10, 13, 14, ... 20, 23, ...
|
|
202
|
+
*/
|
|
203
|
+
export const rule14 = (number: number): number => {
|
|
204
|
+
return number % 10 === 1 ? 1 : number % 10 === 2 ? 2 : 3
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Families: Icelandic
|
|
209
|
+
* 1 - ends in 1, not 11: 1, 21, 31, ... 101, 121, 131, ...
|
|
210
|
+
* 2 - everything else: 0, 2, 3, ... 10, 11, 12, ... 20, 22, ...
|
|
211
|
+
*/
|
|
212
|
+
export const rule15 = (number: number): number => {
|
|
213
|
+
return number % 10 === 1 && number % 100 != 11 ? 1 : 2
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export interface PluralRules {
|
|
217
|
+
[key: number]: (pluralValue: number) => number
|
|
218
|
+
}
|
|
@@ -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()
|