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
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
- })