metaowl 0.4.0 → 0.5.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/CHANGELOG.md +52 -0
- package/README.md +13 -15
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +141 -0
- package/build/runtime/modules/app-mounter.js +65 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +205 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +167 -0
- package/build/runtime/modules/layouts.js +163 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +264 -0
- package/build/runtime/modules/pwa.js +262 -0
- package/build/runtime/modules/router.js +389 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +196 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +183 -0
- package/eslint.js +29 -0
- package/package.json +29 -11
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -277
- package/vitest.config.js +0 -8
package/modules/forms.js
DELETED
|
@@ -1,353 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module Forms
|
|
3
|
-
*
|
|
4
|
-
* Form handling and validation for MetaOwl applications.
|
|
5
|
-
*
|
|
6
|
-
* Features:
|
|
7
|
-
* - useForm() hook for form state
|
|
8
|
-
* - Field-level validation
|
|
9
|
-
* - Schema validation (Zod-compatible)
|
|
10
|
-
* - Error display
|
|
11
|
-
* - Field dirty/touched tracking
|
|
12
|
-
* - Async validation support
|
|
13
|
-
*
|
|
14
|
-
* @example
|
|
15
|
-
* import { useForm } from 'metaowl'
|
|
16
|
-
*
|
|
17
|
-
* const form = useForm({
|
|
18
|
-
* name: { default: '', validation: (v) => v.length > 2 || 'Min 3 chars' },
|
|
19
|
-
* email: { default: '', validation: (v) => /^[^\s@]+@[^\s@]+$/.test(v) || 'Invalid' }
|
|
20
|
-
* })
|
|
21
|
-
*
|
|
22
|
-
* form.handleSubmit((values) => {
|
|
23
|
-
* console.log(values.name, values.email)
|
|
24
|
-
* })
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { reactive } from '@odoo/owl'
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* Create a form controller.
|
|
31
|
-
*
|
|
32
|
-
* @param {object} fieldsConfig - Field configurations
|
|
33
|
-
* @returns {FormController}
|
|
34
|
-
*
|
|
35
|
-
* @example
|
|
36
|
-
* const form = useForm({
|
|
37
|
-
* name: { default: '', validation: (v) => v.length > 0 || 'Required' },
|
|
38
|
-
* email: {
|
|
39
|
-
* default: '',
|
|
40
|
-
* validation: [
|
|
41
|
-
* (v) => !!v || 'Required',
|
|
42
|
-
* (v) => /.+@.+/.test(v) || 'Invalid email'
|
|
43
|
-
* ],
|
|
44
|
-
* asyncValidation: async (v) => await checkEmail(v) || 'Taken'
|
|
45
|
-
* }
|
|
46
|
-
* })
|
|
47
|
-
*/
|
|
48
|
-
export function useForm(fieldsConfig = {}) {
|
|
49
|
-
const fields = {}
|
|
50
|
-
const errors = {}
|
|
51
|
-
const touched = {}
|
|
52
|
-
const dirty = {}
|
|
53
|
-
const validating = {}
|
|
54
|
-
|
|
55
|
-
// Initialize field states
|
|
56
|
-
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
57
|
-
const initialValue = config?.default ?? ''
|
|
58
|
-
fields[name] = initialValue
|
|
59
|
-
errors[name] = null
|
|
60
|
-
touched[name] = false
|
|
61
|
-
dirty[name] = false
|
|
62
|
-
validating[name] = false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const state = reactive({
|
|
66
|
-
fields,
|
|
67
|
-
errors,
|
|
68
|
-
touched,
|
|
69
|
-
dirty,
|
|
70
|
-
validating,
|
|
71
|
-
isSubmitting: false,
|
|
72
|
-
isValidating: false,
|
|
73
|
-
submitCount: 0
|
|
74
|
-
})
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Validate a single field.
|
|
78
|
-
*
|
|
79
|
-
* @param {string} name
|
|
80
|
-
* @returns {Promise<boolean>}
|
|
81
|
-
*/
|
|
82
|
-
async function validateField(name) {
|
|
83
|
-
const config = fieldsConfig[name]
|
|
84
|
-
if (!config?.validation) {
|
|
85
|
-
state.errors[name] = null
|
|
86
|
-
return true
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const value = state.fields[name]
|
|
90
|
-
const validators = Array.isArray(config.validation)
|
|
91
|
-
? config.validation
|
|
92
|
-
: [config.validation]
|
|
93
|
-
|
|
94
|
-
// Sync validation
|
|
95
|
-
for (const validator of validators) {
|
|
96
|
-
const result = validator(value, state.fields)
|
|
97
|
-
if (result !== true) {
|
|
98
|
-
state.errors[name] = result || 'Invalid'
|
|
99
|
-
return false
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Async validation
|
|
104
|
-
if (config.asyncValidation) {
|
|
105
|
-
state.validating[name] = true
|
|
106
|
-
state.isValidating = true
|
|
107
|
-
try {
|
|
108
|
-
const result = await config.asyncValidation(value, state.fields)
|
|
109
|
-
if (result !== true) {
|
|
110
|
-
state.errors[name] = result || 'Invalid'
|
|
111
|
-
return false
|
|
112
|
-
}
|
|
113
|
-
} finally {
|
|
114
|
-
state.validating[name] = false
|
|
115
|
-
state.isValidating = Object.values(state.validating).some(Boolean)
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
state.errors[name] = null
|
|
120
|
-
return true
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/**
|
|
124
|
-
* Validate all fields.
|
|
125
|
-
*
|
|
126
|
-
* @returns {Promise<boolean>}
|
|
127
|
-
*/
|
|
128
|
-
async function validateAll() {
|
|
129
|
-
const results = await Promise.all(
|
|
130
|
-
Object.keys(fieldsConfig).map(name => validateField(name))
|
|
131
|
-
)
|
|
132
|
-
return results.every(Boolean)
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
return {
|
|
136
|
-
// State (reactive)
|
|
137
|
-
fields: state.fields,
|
|
138
|
-
errors: state.errors,
|
|
139
|
-
touched: state.touched,
|
|
140
|
-
dirty: state.dirty,
|
|
141
|
-
validating: state.validating,
|
|
142
|
-
isSubmitting: state.isSubmitting,
|
|
143
|
-
isValidating: state.isValidating,
|
|
144
|
-
submitCount: state.submitCount,
|
|
145
|
-
|
|
146
|
-
// Computed
|
|
147
|
-
get isValid() {
|
|
148
|
-
return Object.values(state.errors).every(e => e === null)
|
|
149
|
-
},
|
|
150
|
-
|
|
151
|
-
get isDirty() {
|
|
152
|
-
return Object.values(state.dirty).some(Boolean)
|
|
153
|
-
},
|
|
154
|
-
|
|
155
|
-
get isTouched() {
|
|
156
|
-
return Object.values(state.touched).some(Boolean)
|
|
157
|
-
},
|
|
158
|
-
|
|
159
|
-
// Methods
|
|
160
|
-
/**
|
|
161
|
-
* Update a field value.
|
|
162
|
-
*
|
|
163
|
-
* @param {string} name
|
|
164
|
-
* @param {*} value
|
|
165
|
-
*/
|
|
166
|
-
setValue(name, value) {
|
|
167
|
-
state.fields[name] = value
|
|
168
|
-
state.dirty[name] = value !== (fieldsConfig[name]?.default ?? '')
|
|
169
|
-
},
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Mark field as touched.
|
|
173
|
-
*
|
|
174
|
-
* @param {string} name
|
|
175
|
-
*/
|
|
176
|
-
setTouched(name) {
|
|
177
|
-
state.touched[name] = true
|
|
178
|
-
},
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Mark all fields as touched.
|
|
182
|
-
*/
|
|
183
|
-
setAllTouched() {
|
|
184
|
-
for (const name of Object.keys(fieldsConfig)) {
|
|
185
|
-
state.touched[name] = true
|
|
186
|
-
}
|
|
187
|
-
},
|
|
188
|
-
|
|
189
|
-
/**
|
|
190
|
-
* Validate a field.
|
|
191
|
-
*
|
|
192
|
-
* @param {string} name
|
|
193
|
-
* @returns {Promise<boolean>}
|
|
194
|
-
*/
|
|
195
|
-
validateField,
|
|
196
|
-
|
|
197
|
-
/**
|
|
198
|
-
* Validate all fields.
|
|
199
|
-
*
|
|
200
|
-
* @returns {Promise<boolean>}
|
|
201
|
-
*/
|
|
202
|
-
validate: validateAll,
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Reset form to initial state.
|
|
206
|
-
*/
|
|
207
|
-
reset() {
|
|
208
|
-
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
209
|
-
state.fields[name] = config?.default ?? ''
|
|
210
|
-
state.errors[name] = null
|
|
211
|
-
state.touched[name] = false
|
|
212
|
-
state.dirty[name] = false
|
|
213
|
-
}
|
|
214
|
-
state.isSubmitting = false
|
|
215
|
-
state.submitCount = 0
|
|
216
|
-
},
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Create submit handler.
|
|
220
|
-
*
|
|
221
|
-
* @param {Function} onSubmit - Callback with form values
|
|
222
|
-
* @param {object} [options]
|
|
223
|
-
* @param {boolean} [options.validate=true] - Validate before submit
|
|
224
|
-
* @returns {Function} Submit handler
|
|
225
|
-
*/
|
|
226
|
-
handleSubmit(onSubmit, options = {}) {
|
|
227
|
-
const { validate = true } = options
|
|
228
|
-
|
|
229
|
-
return async (...args) => {
|
|
230
|
-
state.isSubmitting = true
|
|
231
|
-
state.submitCount++
|
|
232
|
-
|
|
233
|
-
try {
|
|
234
|
-
if (validate) {
|
|
235
|
-
this.setAllTouched()
|
|
236
|
-
const isValid = await this.validate()
|
|
237
|
-
if (!isValid) {
|
|
238
|
-
state.isSubmitting = false
|
|
239
|
-
return
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
await onSubmit({ ...state.fields }, ...args)
|
|
244
|
-
} finally {
|
|
245
|
-
state.isSubmitting = false
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
},
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* Get field props for binding.
|
|
252
|
-
*
|
|
253
|
-
* @param {string} name
|
|
254
|
-
* @returns {object} Bindable props
|
|
255
|
-
*/
|
|
256
|
-
register(name) {
|
|
257
|
-
return {
|
|
258
|
-
value: state.fields[name],
|
|
259
|
-
onChange: (e) => this.setValue(name, e.target?.value ?? e),
|
|
260
|
-
onBlur: () => {
|
|
261
|
-
this.setTouched(name)
|
|
262
|
-
this.validateField(name)
|
|
263
|
-
},
|
|
264
|
-
error: state.touched[name] ? state.errors[name] : null
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
/**
|
|
271
|
-
* Common validators.
|
|
272
|
-
*/
|
|
273
|
-
export const validators = {
|
|
274
|
-
required: (message = 'Required') => (v) => !!v || message,
|
|
275
|
-
|
|
276
|
-
minLength: (min, message) => (v) =>
|
|
277
|
-
(v?.length ?? 0) >= min || message || `Min ${min} characters`,
|
|
278
|
-
|
|
279
|
-
maxLength: (max, message) => (v) =>
|
|
280
|
-
(v?.length ?? 0) <= max || message || `Max ${max} characters`,
|
|
281
|
-
|
|
282
|
-
min: (min, message) => (v) =>
|
|
283
|
-
Number(v) >= min || message || `Min ${min}`,
|
|
284
|
-
|
|
285
|
-
max: (max, message) => (v) =>
|
|
286
|
-
Number(v) <= max || message || `Max ${max}`,
|
|
287
|
-
|
|
288
|
-
email: (message = 'Invalid email') => (v) =>
|
|
289
|
-
/^[^\s@]+@[^\s@]+$/.test(v) || message,
|
|
290
|
-
|
|
291
|
-
url: (message = 'Invalid URL') => (v) =>
|
|
292
|
-
/^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})[/\w .-]*\/?$/.test(v) || message,
|
|
293
|
-
|
|
294
|
-
pattern: (regex, message = 'Invalid format') => (v) =>
|
|
295
|
-
regex.test(v) || message,
|
|
296
|
-
|
|
297
|
-
match: (field, message) => (v, values) =>
|
|
298
|
-
v === values[field] || message || 'Fields do not match'
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Create validation schema from config.
|
|
303
|
-
*
|
|
304
|
-
* @param {object} schema
|
|
305
|
-
* @returns {object}
|
|
306
|
-
*
|
|
307
|
-
* @example
|
|
308
|
-
* const schema = createSchema({
|
|
309
|
-
* name: [validators.required(), validators.minLength(3)],
|
|
310
|
-
* email: [validators.required(), validators.email()]
|
|
311
|
-
* })
|
|
312
|
-
*
|
|
313
|
-
* const form = useForm(schema)
|
|
314
|
-
*/
|
|
315
|
-
export function createSchema(schema) {
|
|
316
|
-
const fieldsConfig = {}
|
|
317
|
-
|
|
318
|
-
for (const [name, validators] of Object.entries(schema)) {
|
|
319
|
-
const validatorArray = Array.isArray(validators) ? validators : [validators]
|
|
320
|
-
|
|
321
|
-
fieldsConfig[name] = {
|
|
322
|
-
default: '',
|
|
323
|
-
validation: (v, values) => {
|
|
324
|
-
for (const validator of validatorArray) {
|
|
325
|
-
const result = validator(v, values)
|
|
326
|
-
if (result !== true) return result
|
|
327
|
-
}
|
|
328
|
-
return true
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
return fieldsConfig
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Field component props helper.
|
|
338
|
-
*
|
|
339
|
-
* @param {object} form - Form controller from useForm
|
|
340
|
-
* @param {string} name - Field name
|
|
341
|
-
* @returns {object} Props for input component
|
|
342
|
-
*/
|
|
343
|
-
export function fieldProps(form, name) {
|
|
344
|
-
return {
|
|
345
|
-
value: form.fields[name],
|
|
346
|
-
error: form.touched[name] ? form.errors[name] : null,
|
|
347
|
-
onChange: (value) => form.setValue(name, value),
|
|
348
|
-
onBlur: () => {
|
|
349
|
-
form.setTouched(name)
|
|
350
|
-
form.validateField(name)
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
package/modules/i18n.js
DELETED
|
@@ -1,333 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @module i18n
|
|
3
|
-
*
|
|
4
|
-
* Internationalization (i18n) support for MetaOwl applications.
|
|
5
|
-
*
|
|
6
|
-
* Features:
|
|
7
|
-
* - Translation function t()
|
|
8
|
-
* - Locale switching
|
|
9
|
-
* - Pluralization support
|
|
10
|
-
* - Interpolation: {{variable}}
|
|
11
|
-
* - Named formats
|
|
12
|
-
* - Async locale loading
|
|
13
|
-
* - Fallback locales
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* import { i18n, t } from 'metaowl'
|
|
17
|
-
*
|
|
18
|
-
* // Configure
|
|
19
|
-
* i18n.configure({
|
|
20
|
-
* locale: 'de',
|
|
21
|
-
* fallbackLocale: 'en',
|
|
22
|
-
* messages: {
|
|
23
|
-
* en: { hello: 'Hello {{name}}' },
|
|
24
|
-
* de: { hello: 'Hallo {{name}}' }
|
|
25
|
-
* }
|
|
26
|
-
* })
|
|
27
|
-
*
|
|
28
|
-
* // Use in templates
|
|
29
|
-
* t('hello', { name: 'World' }) // 'Hallo World'
|
|
30
|
-
*
|
|
31
|
-
* // Use in OWL templates
|
|
32
|
-
* xml`<span t-esc="t('hello', { name: state.user })" />`
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
import { reactive } from '@odoo/owl'
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Reactive i18n state.
|
|
39
|
-
*/
|
|
40
|
-
const _state = reactive({
|
|
41
|
-
locale: 'en',
|
|
42
|
-
fallbackLocale: 'en',
|
|
43
|
-
messages: {},
|
|
44
|
-
loading: false
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Default pluralization rules.
|
|
49
|
-
*/
|
|
50
|
-
const _pluralRules = new Map()
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Configure pluralization for a locale.
|
|
54
|
-
*
|
|
55
|
-
* @param {string} locale
|
|
56
|
-
* @param {Function} rule - (count) => form ('zero'|'one'|'two'|'few'|'many'|'other')
|
|
57
|
-
*/
|
|
58
|
-
export function setPluralizationRule(locale, rule) {
|
|
59
|
-
_pluralRules.set(locale, rule)
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Default pluralization (English-like).
|
|
64
|
-
*
|
|
65
|
-
* @param {number} count
|
|
66
|
-
* @returns {string}
|
|
67
|
-
*/
|
|
68
|
-
function defaultPluralRule(count) {
|
|
69
|
-
if (count === 0) return 'zero'
|
|
70
|
-
if (count === 1) return 'one'
|
|
71
|
-
return 'other'
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Get plural form for count and locale.
|
|
76
|
-
*
|
|
77
|
-
* @param {number} count
|
|
78
|
-
* @param {string} locale
|
|
79
|
-
* @returns {string}
|
|
80
|
-
*/
|
|
81
|
-
function getPluralForm(count, locale) {
|
|
82
|
-
const rule = _pluralRules.get(locale) || defaultPluralRule
|
|
83
|
-
return rule(count)
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Configure i18n settings.
|
|
88
|
-
*
|
|
89
|
-
* @param {object} config
|
|
90
|
-
* @param {string} [config.locale='en']
|
|
91
|
-
* @param {string} [config.fallbackLocale='en']
|
|
92
|
-
* @param {object} [config.messages={}]
|
|
93
|
-
* @param {boolean} [config.warnOnMissing=true]
|
|
94
|
-
*/
|
|
95
|
-
export function configureI18n(config) {
|
|
96
|
-
if (config.locale) {
|
|
97
|
-
_state.locale = config.locale
|
|
98
|
-
document.documentElement.lang = config.locale
|
|
99
|
-
}
|
|
100
|
-
if (config.fallbackLocale) {
|
|
101
|
-
_state.fallbackLocale = config.fallbackLocale
|
|
102
|
-
}
|
|
103
|
-
if (config.messages) {
|
|
104
|
-
_state.messages = config.messages
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* Get current locale.
|
|
110
|
-
*
|
|
111
|
-
* @returns {string}
|
|
112
|
-
*/
|
|
113
|
-
export function getLocale() {
|
|
114
|
-
return _state.locale
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* Set current locale.
|
|
119
|
-
*
|
|
120
|
-
* @param {string} locale
|
|
121
|
-
* @returns {Promise<void>}
|
|
122
|
-
*/
|
|
123
|
-
export async function setLocale(locale) {
|
|
124
|
-
_state.locale = locale
|
|
125
|
-
document.documentElement.lang = locale
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Load locale messages asynchronously.
|
|
130
|
-
*
|
|
131
|
-
* @param {string} locale
|
|
132
|
-
* @param {object|Promise<object>} messages
|
|
133
|
-
*/
|
|
134
|
-
export async function loadLocaleMessages(locale, messages) {
|
|
135
|
-
_state.loading = true
|
|
136
|
-
try {
|
|
137
|
-
const loaded = await messages
|
|
138
|
-
if (!_state.messages[locale]) {
|
|
139
|
-
_state.messages[locale] = {}
|
|
140
|
-
}
|
|
141
|
-
Object.assign(_state.messages[locale], loaded)
|
|
142
|
-
} finally {
|
|
143
|
-
_state.loading = false
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Translate a key with optional interpolation.
|
|
149
|
-
*
|
|
150
|
-
* @param {string} key - Dot-notation key (e.g., 'messages.hello')
|
|
151
|
-
* @param {object} [values] - Interpolation values
|
|
152
|
-
* @param {string} [defaultMessage] - Fallback message
|
|
153
|
-
* @returns {string} Translated string
|
|
154
|
-
*
|
|
155
|
-
* @example
|
|
156
|
-
* t('hello') // 'Hello'
|
|
157
|
-
* t('hello', { name: 'World' }) // 'Hello World'
|
|
158
|
-
* t('count', { n: 5 }) // Uses plural forms
|
|
159
|
-
*/
|
|
160
|
-
export function t(key, values = {}, defaultMessage) {
|
|
161
|
-
const locale = _state.locale
|
|
162
|
-
const fallbackLocale = _state.fallbackLocale
|
|
163
|
-
|
|
164
|
-
// Get message
|
|
165
|
-
let message = getMessage(key, locale)
|
|
166
|
-
|
|
167
|
-
// Fallback
|
|
168
|
-
if (!message && locale !== fallbackLocale) {
|
|
169
|
-
message = getMessage(key, fallbackLocale)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Still not found
|
|
173
|
-
if (!message) {
|
|
174
|
-
return defaultMessage || key
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// Handle pluralization
|
|
178
|
-
if (typeof message === 'object') {
|
|
179
|
-
const count = values.n || values.count || 0
|
|
180
|
-
const form = getPluralForm(count, locale)
|
|
181
|
-
message = message[form] || message.other || message.one || key
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// Interpolate
|
|
185
|
-
return interpolate(message, values)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Get message by dot-notation key.
|
|
190
|
-
*
|
|
191
|
-
* @param {string} key
|
|
192
|
-
* @param {string} locale
|
|
193
|
-
* @returns {string|object|undefined}
|
|
194
|
-
*/
|
|
195
|
-
function getMessage(key, locale) {
|
|
196
|
-
const parts = key.split('.')
|
|
197
|
-
let current = _state.messages[locale]
|
|
198
|
-
|
|
199
|
-
for (const part of parts) {
|
|
200
|
-
if (!current || typeof current !== 'object') {
|
|
201
|
-
return undefined
|
|
202
|
-
}
|
|
203
|
-
current = current[part]
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
return current
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Interpolate values into message.
|
|
211
|
-
*
|
|
212
|
-
* @param {string} message
|
|
213
|
-
* @param {object} values
|
|
214
|
-
* @returns {string}
|
|
215
|
-
*/
|
|
216
|
-
function interpolate(message, values) {
|
|
217
|
-
return message.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
218
|
-
return values[key] !== undefined ? values[key] : match
|
|
219
|
-
})
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* Format a date according to locale.
|
|
224
|
-
*
|
|
225
|
-
* @param {Date|number|string} date
|
|
226
|
-
* @param {object} [options] - Intl.DateTimeFormat options
|
|
227
|
-
* @returns {string}
|
|
228
|
-
*/
|
|
229
|
-
export function formatDate(date, options = {}) {
|
|
230
|
-
const d = new Date(date)
|
|
231
|
-
return new Intl.DateTimeFormat(_state.locale, options).format(d)
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* Format a number according to locale.
|
|
236
|
-
*
|
|
237
|
-
* @param {number} number
|
|
238
|
-
* @param {object} [options] - Intl.NumberFormat options
|
|
239
|
-
* @returns {string}
|
|
240
|
-
*/
|
|
241
|
-
export function formatNumber(number, options = {}) {
|
|
242
|
-
return new Intl.NumberFormat(_state.locale, options).format(number)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Format currency according to locale.
|
|
247
|
-
*
|
|
248
|
-
* @param {number} amount
|
|
249
|
-
* @param {string} currency - Currency code (e.g., 'USD', 'EUR')
|
|
250
|
-
* @param {object} [options]
|
|
251
|
-
* @returns {string}
|
|
252
|
-
*/
|
|
253
|
-
export function formatCurrency(amount, currency, options = {}) {
|
|
254
|
-
return new Intl.NumberFormat(_state.locale, {
|
|
255
|
-
style: 'currency',
|
|
256
|
-
currency,
|
|
257
|
-
...options
|
|
258
|
-
}).format(amount)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Format relative time (e.g., "2 hours ago").
|
|
263
|
-
*
|
|
264
|
-
* @param {Date|number|string} date
|
|
265
|
-
* @param {string} [style='long'] - 'long'|'short'|'narrow'
|
|
266
|
-
* @returns {string}
|
|
267
|
-
*/
|
|
268
|
-
export function formatRelativeTime(date, style = 'long') {
|
|
269
|
-
const d = new Date(date)
|
|
270
|
-
const now = new Date()
|
|
271
|
-
const diff = d.getTime() - now.getTime()
|
|
272
|
-
|
|
273
|
-
const seconds = Math.round(diff / 1000)
|
|
274
|
-
const minutes = Math.round(seconds / 60)
|
|
275
|
-
const hours = Math.round(minutes / 60)
|
|
276
|
-
const days = Math.round(hours / 24)
|
|
277
|
-
|
|
278
|
-
const rtf = new Intl.RelativeTimeFormat(_state.locale, { style })
|
|
279
|
-
|
|
280
|
-
if (Math.abs(seconds) < 60) return rtf.format(seconds, 'second')
|
|
281
|
-
if (Math.abs(minutes) < 60) return rtf.format(minutes, 'minute')
|
|
282
|
-
if (Math.abs(hours) < 24) return rtf.format(hours, 'hour')
|
|
283
|
-
return rtf.format(days, 'day')
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* i18n helper for OWL templates.
|
|
288
|
-
* Use as: t-esc="i18n.t('key', values)"
|
|
289
|
-
*/
|
|
290
|
-
export const i18n = {
|
|
291
|
-
get locale() { return _state.locale },
|
|
292
|
-
get fallbackLocale() { return _state.fallbackLocale },
|
|
293
|
-
get loading() { return _state.loading },
|
|
294
|
-
get messages() { return _state.messages },
|
|
295
|
-
configure: configureI18n,
|
|
296
|
-
setLocale,
|
|
297
|
-
t,
|
|
298
|
-
formatDate,
|
|
299
|
-
formatNumber,
|
|
300
|
-
formatCurrency,
|
|
301
|
-
formatRelativeTime
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/**
|
|
305
|
-
* Create a namespaced translation function.
|
|
306
|
-
*
|
|
307
|
-
* @param {string} namespace - e.g., 'forms', 'errors'
|
|
308
|
-
* @returns {Function} - (key, values) => string
|
|
309
|
-
*
|
|
310
|
-
* @example
|
|
311
|
-
* const tf = createNamespacedT('forms')
|
|
312
|
-
* tf('name.label') // Same as t('forms.name.label')
|
|
313
|
-
*/
|
|
314
|
-
export function createNamespacedT(namespace) {
|
|
315
|
-
return (key, values) => t(`${namespace}.${key}`, values)
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
// Set up German pluralization
|
|
319
|
-
setPluralizationRule('de', (count) => {
|
|
320
|
-
if (count === 0) return 'zero'
|
|
321
|
-
if (count === 1) return 'one'
|
|
322
|
-
return 'other'
|
|
323
|
-
})
|
|
324
|
-
|
|
325
|
-
// Set up Russian pluralization (complex)
|
|
326
|
-
setPluralizationRule('ru', (count) => {
|
|
327
|
-
const mod10 = count % 10
|
|
328
|
-
const mod100 = count % 100
|
|
329
|
-
if (mod10 === 1 && mod100 !== 11) return 'one'
|
|
330
|
-
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100)) return 'few'
|
|
331
|
-
if (mod10 === 0 || [5, 6, 7, 8, 9].includes(mod10) || [11, 12, 13, 14].includes(mod100)) return 'many'
|
|
332
|
-
return 'other'
|
|
333
|
-
})
|