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.
Files changed (80) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +12 -0
  3. package/build/runtime/bin/metaowl-build.js +10 -0
  4. package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
  5. package/build/runtime/bin/metaowl-dev.js +10 -0
  6. package/build/runtime/bin/metaowl-generate.js +231 -0
  7. package/build/runtime/bin/metaowl-lint.js +58 -0
  8. package/build/runtime/bin/utils.js +68 -0
  9. package/build/runtime/index.js +141 -0
  10. package/build/runtime/modules/app-mounter.js +65 -0
  11. package/build/runtime/modules/auto-import.js +140 -0
  12. package/build/runtime/modules/cache.js +49 -0
  13. package/build/runtime/modules/composables.js +353 -0
  14. package/build/runtime/modules/error-boundary.js +116 -0
  15. package/build/runtime/modules/fetch.js +31 -0
  16. package/build/runtime/modules/file-router.js +205 -0
  17. package/build/runtime/modules/forms.js +193 -0
  18. package/build/runtime/modules/i18n.js +167 -0
  19. package/build/runtime/modules/layouts.js +163 -0
  20. package/build/runtime/modules/link.js +141 -0
  21. package/build/runtime/modules/meta.js +117 -0
  22. package/build/runtime/modules/odoo-rpc.js +264 -0
  23. package/build/runtime/modules/pwa.js +262 -0
  24. package/build/runtime/modules/router.js +389 -0
  25. package/build/runtime/modules/seo.js +186 -0
  26. package/build/runtime/modules/store.js +196 -0
  27. package/build/runtime/modules/templates-manager.js +52 -0
  28. package/build/runtime/modules/test-utils.js +238 -0
  29. package/build/runtime/vite/plugin.js +183 -0
  30. package/eslint.js +29 -0
  31. package/package.json +28 -10
  32. package/CONTRIBUTING.md +0 -49
  33. package/bin/metaowl-build.js +0 -12
  34. package/bin/metaowl-dev.js +0 -12
  35. package/bin/metaowl-generate.js +0 -339
  36. package/bin/metaowl-lint.js +0 -71
  37. package/bin/utils.js +0 -82
  38. package/eslint.config.js +0 -3
  39. package/index.js +0 -328
  40. package/modules/app-mounter.js +0 -104
  41. package/modules/auto-import.js +0 -225
  42. package/modules/cache.js +0 -59
  43. package/modules/composables.js +0 -600
  44. package/modules/error-boundary.js +0 -228
  45. package/modules/fetch.js +0 -51
  46. package/modules/file-router.js +0 -478
  47. package/modules/forms.js +0 -353
  48. package/modules/i18n.js +0 -333
  49. package/modules/layouts.js +0 -431
  50. package/modules/link.js +0 -255
  51. package/modules/meta.js +0 -119
  52. package/modules/odoo-rpc.js +0 -511
  53. package/modules/pwa.js +0 -515
  54. package/modules/router.js +0 -769
  55. package/modules/seo.js +0 -501
  56. package/modules/store.js +0 -409
  57. package/modules/templates-manager.js +0 -89
  58. package/modules/test-utils.js +0 -532
  59. package/test/auto-import.test.js +0 -110
  60. package/test/cache.test.js +0 -55
  61. package/test/composables.test.js +0 -103
  62. package/test/dynamic-routes.test.js +0 -469
  63. package/test/error-boundary.test.js +0 -126
  64. package/test/fetch.test.js +0 -100
  65. package/test/file-router.test.js +0 -55
  66. package/test/forms.test.js +0 -203
  67. package/test/i18n.test.js +0 -188
  68. package/test/layouts.test.js +0 -395
  69. package/test/link.test.js +0 -189
  70. package/test/meta.test.js +0 -146
  71. package/test/odoo-rpc.test.js +0 -547
  72. package/test/pwa.test.js +0 -154
  73. package/test/router-guards.test.js +0 -229
  74. package/test/router.test.js +0 -77
  75. package/test/seo.test.js +0 -353
  76. package/test/store.test.js +0 -476
  77. package/test/templates-manager.test.js +0 -83
  78. package/test/test-utils.test.js +0 -314
  79. package/vite/plugin.js +0 -290
  80. 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
+ });