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.
Files changed (79) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +13 -15
  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 +29 -11
  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/index.js +0 -328
  39. package/modules/app-mounter.js +0 -104
  40. package/modules/auto-import.js +0 -225
  41. package/modules/cache.js +0 -59
  42. package/modules/composables.js +0 -600
  43. package/modules/error-boundary.js +0 -228
  44. package/modules/fetch.js +0 -51
  45. package/modules/file-router.js +0 -478
  46. package/modules/forms.js +0 -353
  47. package/modules/i18n.js +0 -333
  48. package/modules/layouts.js +0 -431
  49. package/modules/link.js +0 -255
  50. package/modules/meta.js +0 -119
  51. package/modules/odoo-rpc.js +0 -511
  52. package/modules/pwa.js +0 -515
  53. package/modules/router.js +0 -769
  54. package/modules/seo.js +0 -501
  55. package/modules/store.js +0 -409
  56. package/modules/templates-manager.js +0 -89
  57. package/modules/test-utils.js +0 -532
  58. package/test/auto-import.test.js +0 -110
  59. package/test/cache.test.js +0 -55
  60. package/test/composables.test.js +0 -103
  61. package/test/dynamic-routes.test.js +0 -469
  62. package/test/error-boundary.test.js +0 -126
  63. package/test/fetch.test.js +0 -100
  64. package/test/file-router.test.js +0 -55
  65. package/test/forms.test.js +0 -203
  66. package/test/i18n.test.js +0 -188
  67. package/test/layouts.test.js +0 -395
  68. package/test/link.test.js +0 -189
  69. package/test/meta.test.js +0 -146
  70. package/test/odoo-rpc.test.js +0 -547
  71. package/test/pwa.test.js +0 -154
  72. package/test/router-guards.test.js +0 -229
  73. package/test/router.test.js +0 -77
  74. package/test/seo.test.js +0 -353
  75. package/test/store.test.js +0 -476
  76. package/test/templates-manager.test.js +0 -83
  77. package/test/test-utils.test.js +0 -314
  78. package/vite/plugin.js +0 -277
  79. 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
+ });