metaowl 0.4.1 → 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 +22 -0
- package/README.md +12 -0
- 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 +28 -10
- 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/eslint.config.js +0 -3
- 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 -290
- package/vitest.config.js +0 -8
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module FileRouter
|
|
3
|
+
*
|
|
4
|
+
* File-based routing with dynamic route parameter support.
|
|
5
|
+
*/
|
|
6
|
+
export function pathFromKey(key) {
|
|
7
|
+
const relativePath = key.replace(/^\.\/pages\//, '');
|
|
8
|
+
const parts = relativePath.split('/');
|
|
9
|
+
parts.pop();
|
|
10
|
+
if (parts.length === 0) {
|
|
11
|
+
return '/';
|
|
12
|
+
}
|
|
13
|
+
if (parts.length === 1 && parts[0] === 'index') {
|
|
14
|
+
return '/';
|
|
15
|
+
}
|
|
16
|
+
return '/' + parts.map(segmentToPattern).join('/');
|
|
17
|
+
}
|
|
18
|
+
function segmentToPattern(segment) {
|
|
19
|
+
const insideBrackets = segment.match(/^\[(.+)\]$/);
|
|
20
|
+
if (insideBrackets) {
|
|
21
|
+
const content = insideBrackets[1];
|
|
22
|
+
if (content.startsWith('...')) {
|
|
23
|
+
const paramName = content.slice(3) || 'path';
|
|
24
|
+
return `:${paramName}(.*)`;
|
|
25
|
+
}
|
|
26
|
+
if (content.endsWith('?')) {
|
|
27
|
+
const paramName = content.slice(0, -1);
|
|
28
|
+
return `:${paramName}?`;
|
|
29
|
+
}
|
|
30
|
+
return `:${content}`;
|
|
31
|
+
}
|
|
32
|
+
return segment;
|
|
33
|
+
}
|
|
34
|
+
function extractParamNames(filePath) {
|
|
35
|
+
const params = [];
|
|
36
|
+
const parts = filePath.split('/');
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
const match = part.match(/^\[([^?\]]+)\??\]$|^\[\.\.\.([^\]]+)\]$/);
|
|
39
|
+
if (match) {
|
|
40
|
+
params.push((match[1] || match[2] || 'path'));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return params;
|
|
44
|
+
}
|
|
45
|
+
function buildRegexPattern(path) {
|
|
46
|
+
let pattern = path.replace(/\//g, '\\/');
|
|
47
|
+
pattern = pattern.replace(/:([^/(]+)\(\.\*\)/g, '([^/]+(?:/[^/]+)*)');
|
|
48
|
+
pattern = pattern.replace(/\/:([^/(]+)\?/g, '(?:/([^/]+))?');
|
|
49
|
+
pattern = pattern.replace(/:([^/(\s]+)/g, '([^/]+)');
|
|
50
|
+
return '^' + pattern + '$';
|
|
51
|
+
}
|
|
52
|
+
export function matchRoute(pattern, path) {
|
|
53
|
+
const paramNames = [];
|
|
54
|
+
const paramRegex = /:([^/?(]+)/g;
|
|
55
|
+
let match;
|
|
56
|
+
while ((match = paramRegex.exec(pattern)) !== null) {
|
|
57
|
+
paramNames.push(match[1]);
|
|
58
|
+
}
|
|
59
|
+
const regex = new RegExp(buildRegexPattern(pattern));
|
|
60
|
+
const matches = path.match(regex);
|
|
61
|
+
if (!matches) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const params = {};
|
|
65
|
+
for (let index = 0; index < paramNames.length; index++) {
|
|
66
|
+
const value = matches[index + 1];
|
|
67
|
+
if (value !== undefined) {
|
|
68
|
+
params[paramNames[index]] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { params, pattern };
|
|
72
|
+
}
|
|
73
|
+
export function isDynamicRoute(path) {
|
|
74
|
+
return path.includes(':');
|
|
75
|
+
}
|
|
76
|
+
function componentFromModule(mod, key) {
|
|
77
|
+
if (typeof mod.default === 'function')
|
|
78
|
+
return mod.default;
|
|
79
|
+
const named = Object.values(mod).find((value) => typeof value === 'function');
|
|
80
|
+
if (!named) {
|
|
81
|
+
throw new Error(`[metaowl] No component export found in "${key}"`);
|
|
82
|
+
}
|
|
83
|
+
return named;
|
|
84
|
+
}
|
|
85
|
+
export function buildRoutes(modules) {
|
|
86
|
+
const routes = [];
|
|
87
|
+
for (const [key, mod] of Object.entries(modules)) {
|
|
88
|
+
const derivedPath = pathFromKey(key);
|
|
89
|
+
const component = componentFromModule(mod, key);
|
|
90
|
+
const routeConfig = component.route || {};
|
|
91
|
+
const routePath = typeof routeConfig.path === 'string' ? routeConfig.path : derivedPath;
|
|
92
|
+
const routeName = routePath === '/'
|
|
93
|
+
? 'index'
|
|
94
|
+
: routePath.slice(1).replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
|
|
95
|
+
const route = {
|
|
96
|
+
name: routeName,
|
|
97
|
+
path: [routePath],
|
|
98
|
+
component,
|
|
99
|
+
params: extractParamNames(key),
|
|
100
|
+
meta: component.route?.meta || {}
|
|
101
|
+
};
|
|
102
|
+
if (component.route) {
|
|
103
|
+
Object.assign(route, component.route);
|
|
104
|
+
route.path = [typeof route.path === 'string' ? route.path : routePath];
|
|
105
|
+
route.meta = route.meta || {};
|
|
106
|
+
}
|
|
107
|
+
routes.push(route);
|
|
108
|
+
}
|
|
109
|
+
routes.sort((left, right) => {
|
|
110
|
+
const leftPath = left.path[0];
|
|
111
|
+
const rightPath = right.path[0];
|
|
112
|
+
const leftIsCatchAll = leftPath.includes('(.*)');
|
|
113
|
+
const rightIsCatchAll = rightPath.includes('(.*)');
|
|
114
|
+
if (!leftIsCatchAll && rightIsCatchAll)
|
|
115
|
+
return -1;
|
|
116
|
+
if (leftIsCatchAll && !rightIsCatchAll)
|
|
117
|
+
return 1;
|
|
118
|
+
const leftIsDynamic = isDynamicRoute(leftPath);
|
|
119
|
+
const rightIsDynamic = isDynamicRoute(rightPath);
|
|
120
|
+
if (!leftIsDynamic && rightIsDynamic)
|
|
121
|
+
return -1;
|
|
122
|
+
if (leftIsDynamic && !rightIsDynamic)
|
|
123
|
+
return 1;
|
|
124
|
+
if (leftIsDynamic && rightIsDynamic) {
|
|
125
|
+
const leftSegments = leftPath.split('/').length;
|
|
126
|
+
const rightSegments = rightPath.split('/').length;
|
|
127
|
+
if (leftSegments !== rightSegments)
|
|
128
|
+
return rightSegments - leftSegments;
|
|
129
|
+
const leftParamCount = left.params?.length || 0;
|
|
130
|
+
const rightParamCount = right.params?.length || 0;
|
|
131
|
+
return leftParamCount - rightParamCount;
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
});
|
|
135
|
+
return routes;
|
|
136
|
+
}
|
|
137
|
+
export function findRoute(routes, path) {
|
|
138
|
+
for (const route of routes) {
|
|
139
|
+
for (const routePath of route.path) {
|
|
140
|
+
const match = matchRoute(routePath, path);
|
|
141
|
+
if (match) {
|
|
142
|
+
return {
|
|
143
|
+
...route,
|
|
144
|
+
matchedPath: routePath,
|
|
145
|
+
params: match.params
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
export function generateUrl(routes, name, params = {}) {
|
|
153
|
+
const route = routes.find((candidate) => candidate.name === name);
|
|
154
|
+
if (!route) {
|
|
155
|
+
throw new Error(`[metaowl] Route "${name}" not found`);
|
|
156
|
+
}
|
|
157
|
+
let path = route.path[0];
|
|
158
|
+
for (const [key, value] of Object.entries(params)) {
|
|
159
|
+
path = path.replace(`:${key}`, value);
|
|
160
|
+
path = path.replace(`:${key}?`, value);
|
|
161
|
+
}
|
|
162
|
+
return path.replace(/\/:[^/]+\?/g, '').replace(/\?$/, '');
|
|
163
|
+
}
|
|
164
|
+
export function validateRouteParams(route, params) {
|
|
165
|
+
const required = route.params || [];
|
|
166
|
+
const provided = Object.keys(params);
|
|
167
|
+
const missing = required.filter((param) => !provided.includes(param));
|
|
168
|
+
const extra = provided.filter((param) => !required.includes(param));
|
|
169
|
+
return {
|
|
170
|
+
valid: missing.length === 0,
|
|
171
|
+
missing,
|
|
172
|
+
extra
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
export function parseCurrentRoute(routes) {
|
|
176
|
+
return findRoute(routes, document.location.pathname);
|
|
177
|
+
}
|
|
178
|
+
export function defineRoute(config) {
|
|
179
|
+
return config;
|
|
180
|
+
}
|
|
181
|
+
export function route(config) {
|
|
182
|
+
return function decorator(componentClass) {
|
|
183
|
+
componentClass.route = config;
|
|
184
|
+
return componentClass;
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
export function createCatchAllRoute(component, options = {}) {
|
|
188
|
+
return {
|
|
189
|
+
name: options.name || '404',
|
|
190
|
+
path: ['/:path(.*)'],
|
|
191
|
+
component,
|
|
192
|
+
params: ['path'],
|
|
193
|
+
meta: { ...options.meta, catchAll: true }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export function createRedirectRoute(from, to) {
|
|
197
|
+
const name = from.replace(/^\//, '').replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-');
|
|
198
|
+
return {
|
|
199
|
+
name: `redirect-${name}`,
|
|
200
|
+
path: [from],
|
|
201
|
+
redirect: to,
|
|
202
|
+
component: null,
|
|
203
|
+
meta: {}
|
|
204
|
+
};
|
|
205
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Forms
|
|
3
|
+
*
|
|
4
|
+
* Form handling and validation for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
import { reactive } from '@odoo/owl';
|
|
7
|
+
function getChangedValue(event) {
|
|
8
|
+
if (typeof event === 'object' && event !== null && 'target' in event) {
|
|
9
|
+
const target = event.target;
|
|
10
|
+
return target?.value ?? event;
|
|
11
|
+
}
|
|
12
|
+
return event;
|
|
13
|
+
}
|
|
14
|
+
export function useForm(fieldsConfig = {}) {
|
|
15
|
+
const fields = {};
|
|
16
|
+
const errors = {};
|
|
17
|
+
const touched = {};
|
|
18
|
+
const dirty = {};
|
|
19
|
+
const validating = {};
|
|
20
|
+
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
21
|
+
const initialValue = config?.default ?? '';
|
|
22
|
+
fields[name] = initialValue;
|
|
23
|
+
errors[name] = null;
|
|
24
|
+
touched[name] = false;
|
|
25
|
+
dirty[name] = false;
|
|
26
|
+
validating[name] = false;
|
|
27
|
+
}
|
|
28
|
+
const state = reactive({
|
|
29
|
+
fields,
|
|
30
|
+
errors,
|
|
31
|
+
touched,
|
|
32
|
+
dirty,
|
|
33
|
+
validating,
|
|
34
|
+
isSubmitting: false,
|
|
35
|
+
isValidating: false,
|
|
36
|
+
submitCount: 0
|
|
37
|
+
});
|
|
38
|
+
async function validateField(name) {
|
|
39
|
+
const config = fieldsConfig[name];
|
|
40
|
+
if (!config?.validation) {
|
|
41
|
+
state.errors[name] = null;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const value = state.fields[name];
|
|
45
|
+
const validatorList = Array.isArray(config.validation)
|
|
46
|
+
? config.validation
|
|
47
|
+
: [config.validation];
|
|
48
|
+
for (const validator of validatorList) {
|
|
49
|
+
const result = validator(value, state.fields);
|
|
50
|
+
if (result !== true) {
|
|
51
|
+
state.errors[name] = result || 'Invalid';
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (config.asyncValidation) {
|
|
56
|
+
state.validating[name] = true;
|
|
57
|
+
state.isValidating = true;
|
|
58
|
+
try {
|
|
59
|
+
const result = await config.asyncValidation(value, state.fields);
|
|
60
|
+
if (result !== true) {
|
|
61
|
+
state.errors[name] = result || 'Invalid';
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
state.validating[name] = false;
|
|
67
|
+
state.isValidating = Object.values(state.validating).some(Boolean);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
state.errors[name] = null;
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
async function validateAll() {
|
|
74
|
+
const results = await Promise.all(Object.keys(fieldsConfig).map((name) => validateField(name)));
|
|
75
|
+
return results.every(Boolean);
|
|
76
|
+
}
|
|
77
|
+
const controller = {
|
|
78
|
+
fields: state.fields,
|
|
79
|
+
errors: state.errors,
|
|
80
|
+
touched: state.touched,
|
|
81
|
+
dirty: state.dirty,
|
|
82
|
+
validating: state.validating,
|
|
83
|
+
isSubmitting: state.isSubmitting,
|
|
84
|
+
isValidating: state.isValidating,
|
|
85
|
+
submitCount: state.submitCount,
|
|
86
|
+
get isValid() {
|
|
87
|
+
return Object.values(state.errors).every((error) => error === null);
|
|
88
|
+
},
|
|
89
|
+
get isDirty() {
|
|
90
|
+
return Object.values(state.dirty).some(Boolean);
|
|
91
|
+
},
|
|
92
|
+
get isTouched() {
|
|
93
|
+
return Object.values(state.touched).some(Boolean);
|
|
94
|
+
},
|
|
95
|
+
setValue(name, value) {
|
|
96
|
+
state.fields[name] = value;
|
|
97
|
+
state.dirty[name] = value !== (fieldsConfig[name]?.default ?? '');
|
|
98
|
+
},
|
|
99
|
+
setTouched(name) {
|
|
100
|
+
state.touched[name] = true;
|
|
101
|
+
},
|
|
102
|
+
setAllTouched() {
|
|
103
|
+
for (const name of Object.keys(fieldsConfig)) {
|
|
104
|
+
state.touched[name] = true;
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
validateField,
|
|
108
|
+
validate: validateAll,
|
|
109
|
+
reset() {
|
|
110
|
+
for (const [name, config] of Object.entries(fieldsConfig)) {
|
|
111
|
+
state.fields[name] = config?.default ?? '';
|
|
112
|
+
state.errors[name] = null;
|
|
113
|
+
state.touched[name] = false;
|
|
114
|
+
state.dirty[name] = false;
|
|
115
|
+
}
|
|
116
|
+
state.isSubmitting = false;
|
|
117
|
+
state.submitCount = 0;
|
|
118
|
+
},
|
|
119
|
+
handleSubmit(onSubmit, options = {}) {
|
|
120
|
+
const { validate = true } = options;
|
|
121
|
+
return async (...args) => {
|
|
122
|
+
state.isSubmitting = true;
|
|
123
|
+
state.submitCount++;
|
|
124
|
+
try {
|
|
125
|
+
if (validate) {
|
|
126
|
+
controller.setAllTouched();
|
|
127
|
+
const isValid = await controller.validate();
|
|
128
|
+
if (!isValid) {
|
|
129
|
+
state.isSubmitting = false;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
await onSubmit({ ...state.fields }, ...args);
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
state.isSubmitting = false;
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
register(name) {
|
|
141
|
+
return {
|
|
142
|
+
value: state.fields[name],
|
|
143
|
+
onChange: (event) => controller.setValue(name, getChangedValue(event)),
|
|
144
|
+
onBlur: () => {
|
|
145
|
+
controller.setTouched(name);
|
|
146
|
+
void controller.validateField(name);
|
|
147
|
+
},
|
|
148
|
+
error: state.touched[name] ? state.errors[name] : null
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
return controller;
|
|
153
|
+
}
|
|
154
|
+
export const validators = {
|
|
155
|
+
required: (message = 'Required') => (value) => Boolean(value) || message,
|
|
156
|
+
minLength: (min, message) => (value) => (value?.length ?? 0) >= min || message || `Min ${min} characters`,
|
|
157
|
+
maxLength: (max, message) => (value) => (value?.length ?? 0) <= max || message || `Max ${max} characters`,
|
|
158
|
+
min: (min, message) => (value) => Number(value) >= min || message || `Min ${min}`,
|
|
159
|
+
max: (max, message) => (value) => Number(value) <= max || message || `Max ${max}`,
|
|
160
|
+
email: (message = 'Invalid email') => (value) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value ?? '')) || message,
|
|
161
|
+
url: (message = 'Invalid URL') => (value) => /^(https?:\/\/)?([\da-z.-]+)\.([a-z.]{2,6})[/\w .-]*\/?$/.test(String(value ?? '')) || message,
|
|
162
|
+
pattern: (regex, message = 'Invalid format') => (value) => regex.test(String(value ?? '')) || message,
|
|
163
|
+
match: (field, message) => (value, values) => value === values[field] || message || 'Fields do not match'
|
|
164
|
+
};
|
|
165
|
+
export function createSchema(schema) {
|
|
166
|
+
const fieldsConfig = {};
|
|
167
|
+
for (const [name, fieldValidators] of Object.entries(schema)) {
|
|
168
|
+
const validatorArray = Array.isArray(fieldValidators) ? fieldValidators : [fieldValidators];
|
|
169
|
+
fieldsConfig[name] = {
|
|
170
|
+
default: '',
|
|
171
|
+
validation: (value, values) => {
|
|
172
|
+
for (const validator of validatorArray) {
|
|
173
|
+
const result = validator(value, values);
|
|
174
|
+
if (result !== true)
|
|
175
|
+
return result;
|
|
176
|
+
}
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return fieldsConfig;
|
|
182
|
+
}
|
|
183
|
+
export function fieldProps(form, name) {
|
|
184
|
+
return {
|
|
185
|
+
value: form.fields[name],
|
|
186
|
+
error: form.touched[name] ? form.errors[name] : null,
|
|
187
|
+
onChange: (value) => form.setValue(name, getChangedValue(value)),
|
|
188
|
+
onBlur: () => {
|
|
189
|
+
form.setTouched(name);
|
|
190
|
+
void form.validateField(name);
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module i18n
|
|
3
|
+
*
|
|
4
|
+
* Internationalization (i18n) support for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
import { reactive } from '@odoo/owl';
|
|
7
|
+
const state = reactive({
|
|
8
|
+
locale: 'en',
|
|
9
|
+
fallbackLocale: 'en',
|
|
10
|
+
messages: {},
|
|
11
|
+
loading: false
|
|
12
|
+
});
|
|
13
|
+
const pluralRules = new Map();
|
|
14
|
+
export function setPluralizationRule(locale, rule) {
|
|
15
|
+
pluralRules.set(locale, rule);
|
|
16
|
+
}
|
|
17
|
+
function defaultPluralRule(count) {
|
|
18
|
+
if (count === 0)
|
|
19
|
+
return 'zero';
|
|
20
|
+
if (count === 1)
|
|
21
|
+
return 'one';
|
|
22
|
+
return 'other';
|
|
23
|
+
}
|
|
24
|
+
function getPluralForm(count, locale) {
|
|
25
|
+
const rule = pluralRules.get(locale) || defaultPluralRule;
|
|
26
|
+
return rule(count);
|
|
27
|
+
}
|
|
28
|
+
export function configureI18n(config) {
|
|
29
|
+
if (config.locale) {
|
|
30
|
+
state.locale = config.locale;
|
|
31
|
+
document.documentElement.lang = config.locale;
|
|
32
|
+
}
|
|
33
|
+
if (config.fallbackLocale) {
|
|
34
|
+
state.fallbackLocale = config.fallbackLocale;
|
|
35
|
+
}
|
|
36
|
+
if (config.messages) {
|
|
37
|
+
state.messages = config.messages;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function getLocale() {
|
|
41
|
+
return state.locale;
|
|
42
|
+
}
|
|
43
|
+
export async function setLocale(locale) {
|
|
44
|
+
state.locale = locale;
|
|
45
|
+
document.documentElement.lang = locale;
|
|
46
|
+
}
|
|
47
|
+
export async function loadLocaleMessages(locale, messages) {
|
|
48
|
+
state.loading = true;
|
|
49
|
+
try {
|
|
50
|
+
const loaded = await messages;
|
|
51
|
+
if (!state.messages[locale]) {
|
|
52
|
+
state.messages[locale] = {};
|
|
53
|
+
}
|
|
54
|
+
Object.assign(state.messages[locale], loaded);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
state.loading = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function t(key, values = {}, defaultMessage) {
|
|
61
|
+
const locale = state.locale;
|
|
62
|
+
const fallbackLocale = state.fallbackLocale;
|
|
63
|
+
let message = getMessage(key, locale);
|
|
64
|
+
if (!message && locale !== fallbackLocale) {
|
|
65
|
+
message = getMessage(key, fallbackLocale);
|
|
66
|
+
}
|
|
67
|
+
if (!message) {
|
|
68
|
+
return defaultMessage || key;
|
|
69
|
+
}
|
|
70
|
+
if (isPluralMessage(message)) {
|
|
71
|
+
const countValue = values.n ?? values.count ?? 0;
|
|
72
|
+
const count = typeof countValue === 'number' ? countValue : Number(countValue);
|
|
73
|
+
const form = getPluralForm(Number.isNaN(count) ? 0 : count, locale);
|
|
74
|
+
message = message[form] || message.other || message.one || key;
|
|
75
|
+
}
|
|
76
|
+
if (typeof message !== 'string') {
|
|
77
|
+
return defaultMessage || key;
|
|
78
|
+
}
|
|
79
|
+
return interpolate(message, values);
|
|
80
|
+
}
|
|
81
|
+
function getMessage(key, locale) {
|
|
82
|
+
const parts = key.split('.');
|
|
83
|
+
let current = state.messages[locale];
|
|
84
|
+
for (const part of parts) {
|
|
85
|
+
if (!current || typeof current !== 'object') {
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
current = current[part];
|
|
89
|
+
}
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
function isPluralMessage(message) {
|
|
93
|
+
if (!message || typeof message !== 'object' || Array.isArray(message)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
const keys = Object.keys(message);
|
|
97
|
+
return keys.some((key) => ['zero', 'one', 'two', 'few', 'many', 'other'].includes(key));
|
|
98
|
+
}
|
|
99
|
+
function interpolate(message, values) {
|
|
100
|
+
return message.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
|
101
|
+
return values[key] !== undefined ? String(values[key]) : match;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export function formatDate(date, options = {}) {
|
|
105
|
+
return new Intl.DateTimeFormat(state.locale, options).format(new Date(date));
|
|
106
|
+
}
|
|
107
|
+
export function formatNumber(value, options = {}) {
|
|
108
|
+
return new Intl.NumberFormat(state.locale, options).format(value);
|
|
109
|
+
}
|
|
110
|
+
export function formatCurrency(amount, currency, options = {}) {
|
|
111
|
+
return new Intl.NumberFormat(state.locale, {
|
|
112
|
+
style: 'currency',
|
|
113
|
+
currency,
|
|
114
|
+
...options
|
|
115
|
+
}).format(amount);
|
|
116
|
+
}
|
|
117
|
+
export function formatRelativeTime(date, style = 'long') {
|
|
118
|
+
const targetDate = new Date(date);
|
|
119
|
+
const now = new Date();
|
|
120
|
+
const diff = targetDate.getTime() - now.getTime();
|
|
121
|
+
const seconds = Math.round(diff / 1000);
|
|
122
|
+
const minutes = Math.round(seconds / 60);
|
|
123
|
+
const hours = Math.round(minutes / 60);
|
|
124
|
+
const days = Math.round(hours / 24);
|
|
125
|
+
const rtf = new Intl.RelativeTimeFormat(state.locale, { style });
|
|
126
|
+
if (Math.abs(seconds) < 60)
|
|
127
|
+
return rtf.format(seconds, 'second');
|
|
128
|
+
if (Math.abs(minutes) < 60)
|
|
129
|
+
return rtf.format(minutes, 'minute');
|
|
130
|
+
if (Math.abs(hours) < 24)
|
|
131
|
+
return rtf.format(hours, 'hour');
|
|
132
|
+
return rtf.format(days, 'day');
|
|
133
|
+
}
|
|
134
|
+
export const i18n = {
|
|
135
|
+
get locale() { return state.locale; },
|
|
136
|
+
get fallbackLocale() { return state.fallbackLocale; },
|
|
137
|
+
get loading() { return state.loading; },
|
|
138
|
+
get messages() { return state.messages; },
|
|
139
|
+
configure: configureI18n,
|
|
140
|
+
setLocale,
|
|
141
|
+
t,
|
|
142
|
+
formatDate,
|
|
143
|
+
formatNumber,
|
|
144
|
+
formatCurrency,
|
|
145
|
+
formatRelativeTime
|
|
146
|
+
};
|
|
147
|
+
export function createNamespacedT(namespace) {
|
|
148
|
+
return (key, values) => t(`${namespace}.${key}`, values);
|
|
149
|
+
}
|
|
150
|
+
setPluralizationRule('de', (count) => {
|
|
151
|
+
if (count === 0)
|
|
152
|
+
return 'zero';
|
|
153
|
+
if (count === 1)
|
|
154
|
+
return 'one';
|
|
155
|
+
return 'other';
|
|
156
|
+
});
|
|
157
|
+
setPluralizationRule('ru', (count) => {
|
|
158
|
+
const mod10 = count % 10;
|
|
159
|
+
const mod100 = count % 100;
|
|
160
|
+
if (mod10 === 1 && mod100 !== 11)
|
|
161
|
+
return 'one';
|
|
162
|
+
if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100))
|
|
163
|
+
return 'few';
|
|
164
|
+
if (mod10 === 0 || [5, 6, 7, 8, 9].includes(mod10) || [11, 12, 13, 14].includes(mod100))
|
|
165
|
+
return 'many';
|
|
166
|
+
return 'other';
|
|
167
|
+
});
|