metaowl 0.1.2 → 0.2.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.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * @module ErrorBoundary
3
+ *
4
+ * Error boundaries for OWL applications. Catches JavaScript errors anywhere
5
+ * in their child component tree, logs those errors, and displays a fallback UI.
6
+ *
7
+ * Features:
8
+ * - Component-level error boundaries
9
+ * - Global error handler
10
+ * - Custom fallback components
11
+ * - Error logging hooks
12
+ * - Error page routing (404, 500)
13
+ *
14
+ * @example
15
+ * // Wrap component with error boundary
16
+ * export default class MyPage extends Component {
17
+ * static template = 'MyPage'
18
+ * static errorBoundary = true
19
+ * static fallback = ErrorFallback
20
+ *
21
+ * onError(error, errorInfo) {
22
+ * console.error('Caught error:', error)
23
+ * }
24
+ * }
25
+ *
26
+ * // Global error handler
27
+ * onError((error, context) => {
28
+ * sendToAnalytics(error, context)
29
+ * })
30
+ */
31
+
32
+ import { Component } from '@odoo/owl'
33
+
34
+ /**
35
+ * Global error handlers registry.
36
+ * @type {Function[]}
37
+ */
38
+ const _globalErrorHandlers = []
39
+
40
+ /**
41
+ * Global error context (component name, route, etc.)
42
+ * @type {object}
43
+ */
44
+ let _errorContext = {}
45
+
46
+ /**
47
+ * Error boundary wrapper component.
48
+ * Renders children and catches errors during rendering/lifecycle.
49
+ */
50
+ export class ErrorBoundary extends Component {
51
+ static template = xml`
52
+ <t t-if="state.hasError">
53
+ <t t-component="props.Fallback || fallback"
54
+ t-props="{ error: state.error, errorInfo: state.errorInfo }"/>
55
+ </t>
56
+ <t t-else="">
57
+ <t t-slot="default"/>
58
+ </t>
59
+ `
60
+
61
+ static defaultProps = {
62
+ Fallback: null
63
+ }
64
+
65
+ setup() {
66
+ this.state = useState({
67
+ hasError: false,
68
+ error: null,
69
+ errorInfo: null
70
+ })
71
+ }
72
+
73
+ onError(error, errorInfo) {
74
+ this.state.hasError = true
75
+ this.state.error = error
76
+ this.state.errorInfo = errorInfo
77
+
78
+ // Call global error handlers
79
+ for (const handler of _globalErrorHandlers) {
80
+ handler(error, { ..._errorContext, ...errorInfo })
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Default fallback component showing error details.
87
+ */
88
+ export class DefaultErrorFallback extends Component {
89
+ static template = xml`
90
+ <div class="error-boundary-fallback">
91
+ <h2>Something went wrong</h2>
92
+ <t t-if="props.error">
93
+ <details>
94
+ <summary>Error details</summary>
95
+ <pre t-esc="props.error.stack || props.error.message || props.error"/>
96
+ </details>
97
+ </t>
98
+ </div>
99
+ `
100
+ }
101
+
102
+ /**
103
+ * Register a global error handler.
104
+ *
105
+ * @param {Function} handler - (error, context) => void
106
+ * @returns {Function} Unsubscribe function
107
+ */
108
+ export function onError(handler) {
109
+ _globalErrorHandlers.push(handler)
110
+ return () => {
111
+ const index = _globalErrorHandlers.indexOf(handler)
112
+ if (index > -1) {
113
+ _globalErrorHandlers.splice(index, 1)
114
+ }
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Set global error context (e.g., current route, user info).
120
+ *
121
+ * @param {object} context
122
+ */
123
+ export function setErrorContext(context) {
124
+ _errorContext = { ..._errorContext, ...context }
125
+ }
126
+
127
+ /**
128
+ * Get current error context.
129
+ *
130
+ * @returns {object}
131
+ */
132
+ export function getErrorContext() {
133
+ return { ..._errorContext }
134
+ }
135
+
136
+ /**
137
+ * Clear global error context.
138
+ */
139
+ export function clearErrorContext() {
140
+ _errorContext = {}
141
+ }
142
+
143
+ /**
144
+ * Capture and report an error manually.
145
+ *
146
+ * @param {Error} error
147
+ * @param {object} [context]
148
+ */
149
+ export function captureError(error, context = {}) {
150
+ const fullContext = { ..._errorContext, ...context }
151
+ for (const handler of _globalErrorHandlers) {
152
+ handler(error, fullContext)
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Error boundary decorator for component classes.
158
+ *
159
+ * @param {object} options
160
+ * @param {boolean} [options.enabled=true]
161
+ * @param {typeof Component} [options.Fallback]
162
+ * @returns {Function} Decorator
163
+ *
164
+ * @example
165
+ * @errorBoundary({ Fallback: CustomFallback })
166
+ * export class MyComponent extends Component { }
167
+ */
168
+ export function errorBoundary(options = {}) {
169
+ return function decorator(ComponentClass) {
170
+ ComponentClass.errorBoundary = true
171
+ if (options.Fallback) {
172
+ ComponentClass.fallback = options.Fallback
173
+ }
174
+ return ComponentClass
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Create an error boundary wrapper for a component.
180
+ *
181
+ * @param {typeof Component} ComponentClass
182
+ * @param {object} [options]
183
+ * @param {typeof Component} [options.Fallback]
184
+ * @returns {typeof Component} Wrapped component
185
+ */
186
+ export function withErrorBoundary(ComponentClass, options = {}) {
187
+ return class extends Component {
188
+ static template = xml`
189
+ <ErrorBoundary Fallback="props.Fallback || fallback">
190
+ <t t-component="Component" t-props="props"/>
191
+ </ErrorBoundary>
192
+ `
193
+
194
+ static components = { ErrorBoundary }
195
+
196
+ setup() {
197
+ this.Component = ComponentClass
198
+ this.fallback = options.Fallback || DefaultErrorFallback
199
+ }
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Initialize global error handling.
205
+ * Sets up window.onerror and window.onunhandledrejection.
206
+ */
207
+ export function initGlobalErrorHandling() {
208
+ // Catch global errors
209
+ window.onerror = (message, source, lineno, colno, error) => {
210
+ captureError(error || new Error(message), {
211
+ type: 'window.onerror',
212
+ source,
213
+ lineno,
214
+ colno
215
+ })
216
+ return false // Don't prevent default handling
217
+ }
218
+
219
+ // Catch unhandled promise rejections
220
+ window.onunhandledrejection = (event) => {
221
+ captureError(event.reason, {
222
+ type: 'unhandledrejection'
223
+ })
224
+ }
225
+ }
226
+
227
+ // Import at end to avoid circular dependency
228
+ import { useState, xml } from '@odoo/owl'
package/modules/fetch.js CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * @module Fetch
3
+ *
4
+ * A static class wrapping the Fetch API with a configurable base URL and
5
+ * error handling. All internal requests automatically prepend the configured
6
+ * baseUrl and return parsed JSON.
7
+ */
1
8
  export default class Fetch {
2
9
  static _baseUrl = ''
3
10
  static _onError = null
@@ -1,29 +1,241 @@
1
+ /**
2
+ * @module FileRouter
3
+ *
4
+ * File-based routing with dynamic route parameter support.
5
+ *
6
+ * Convention (mirrors Nuxt/Next.js):
7
+ * File: pages/index/Index.js → URL: /
8
+ * File: pages/about/About.js → URL: /about
9
+ * File: pages/blog/post/Post.js → URL: /blog/post
10
+ *
11
+ * Dynamic Routes:
12
+ * File: pages/user/[id]/User.js → URL: /user/:id
13
+ * File: pages/product/[category]/[slug].js → URL: /product/:category/:slug
14
+ * File: pages/docs/[...path].js → URL: /docs/:path(.*)
15
+ *
16
+ * Optional Parameters:
17
+ * File: pages/blog/[id]/[slug]?/Blog.js → URL: /blog/:id/:slug?
18
+ *
19
+ * Catch-all Routes:
20
+ * File: pages/[...404].js → URL: /:path(.*)
21
+ */
22
+
23
+ /**
24
+ * Pattern types for route segments.
25
+ */
26
+ const PATTERNS = {
27
+ // [param] → required parameter
28
+ PARAM: /^\[([^?]+)\]$/,
29
+ // [param]? → optional parameter
30
+ OPTIONAL: /^\[([^?]+)\?\]$/,
31
+ // [...param] → catch-all parameter
32
+ CATCH_ALL: /^\.\.\.(.*)$/,
33
+ // Regular segment
34
+ NORMAL: /^[^\[]+$/
35
+ }
36
+
37
+ /**
38
+ * Converts a file path segment to a route pattern segment.
39
+ *
40
+ * @param {string} segment - Path segment (e.g., [id], about, [...slug])
41
+ * @returns {string} Route pattern segment (e.g., :id, about, :slug(.*))
42
+ */
43
+ function segmentToPattern(segment) {
44
+ // Check for catch-all [...something]
45
+ const insideBrackets = segment.match(/^\[(.+)\]$/)
46
+ if (insideBrackets) {
47
+ const content = insideBrackets[1]
48
+
49
+ // Check for spread: [...param]
50
+ if (content.startsWith('...')) {
51
+ const paramName = content.slice(3) || 'path'
52
+ return `:${paramName}(.*)`
53
+ }
54
+
55
+ // Check for optional: [param?]
56
+ if (content.endsWith('?')) {
57
+ const paramName = content.slice(0, -1)
58
+ return `:${paramName}?`
59
+ }
60
+
61
+ // Regular parameter: [param]
62
+ return `:${content}`
63
+ }
64
+
65
+ // Normal segment
66
+ return segment
67
+ }
68
+
69
+ /**
70
+ * Checks if a segment is a dynamic parameter.
71
+ *
72
+ * @param {string} segment - Path segment
73
+ * @returns {boolean}
74
+ */
75
+ function isDynamicSegment(segment) {
76
+ return segment.startsWith('[') && segment.endsWith(']')
77
+ }
78
+
79
+ /**
80
+ * Checks if a segment is an optional parameter.
81
+ *
82
+ * @param {string} segment - Path segment
83
+ * @returns {boolean}
84
+ */
85
+ function isOptionalSegment(segment) {
86
+ return segment.startsWith('[') && segment.endsWith('?]')
87
+ }
88
+
89
+ /**
90
+ * Extracts parameter names from a file path.
91
+ *
92
+ * @param {string} filePath - Relative file path
93
+ * @returns {string[]} Parameter names in order
94
+ */
95
+ function extractParamNames(filePath) {
96
+ const params = []
97
+ const parts = filePath.split('/')
98
+
99
+ for (const part of parts) {
100
+ const match = part.match(/^\[([^?\]]+)\??\]$|^\[\.\.\.([^\]]+)\]$/)
101
+ if (match) {
102
+ params.push(match[1] || match[2] || 'path')
103
+ }
104
+ }
105
+
106
+ return params
107
+ }
108
+
1
109
  /**
2
110
  * Derives a URL path from an import.meta.glob key.
3
111
  *
4
112
  * Convention (mirrors Nuxt/Next.js file-based routing):
5
- * './pages/index/Index.js''/'
6
- * './pages/about/About.js''/about'
7
- * './pages/about/bla/Bla.js' '/about/bla'
113
+ * Key: ./pages/index/Index.js → URL: /
114
+ * Key: ./pages/about/About.js → URL: /about
115
+ * Key: ./pages/about/bla/Bla.js URL: /about/bla
116
+ * Key: ./pages/user/[id]/User.js → URL: /user/:id
117
+ * Key: ./pages/[...404].js → URL: /:path(.*)
8
118
  *
9
119
  * Rule: the *directory* path relative to pages/ becomes the URL.
10
120
  * A top-level directory named 'index' maps to '/'.
121
+ * Dynamic segments use [param] syntax and become route parameters.
11
122
  *
12
123
  * @param {string} key - import.meta.glob key, e.g. './pages/about/About.js'
13
- * @returns {string} URL path
124
+ * @returns {string} URL pattern
14
125
  */
15
126
  function pathFromKey(key) {
16
127
  // Strip leading './' and 'pages/' prefix
17
128
  const rel = key.replace(/^\.\/pages\//, '')
18
- // Drop the filename, keep directory segments
19
- const dirParts = rel.split('/').slice(0, -1)
20
- if (dirParts.length === 1 && dirParts[0] === 'index') return '/'
21
- return '/' + dirParts.join('/')
129
+ // Get all segments
130
+ const parts = rel.split('/')
131
+ // Remove filename (last segment)
132
+ parts.pop()
133
+
134
+ if (parts.length === 0) {
135
+ return '/'
136
+ }
137
+
138
+ // Check for single 'index'
139
+ if (parts.length === 1 && parts[0] === 'index') {
140
+ return '/'
141
+ }
142
+
143
+ // Convert segments to route patterns
144
+ const routeParts = parts.map(segmentToPattern)
145
+
146
+ return '/' + routeParts.join('/')
147
+ }
148
+
149
+ /**
150
+ * Builds a display path for documentation (without parameter syntax).
151
+ *
152
+ * @param {string} pattern - Route pattern like '/user/:id'
153
+ * @returns {string} Display path like '/user/[id]'
154
+ */
155
+ function patternToDisplay(pattern) {
156
+ return pattern
157
+ .replace(/:([^/(]+)\?/g, '[$1?]')
158
+ .replace(/:\(([^)]+)\)/g, '[]')
159
+ .replace(/:([^/(]+)(?:\([^)]*\))?/g, '[$1]')
160
+ }
161
+
162
+ /**
163
+ * Builds a regex pattern string from a route path.
164
+ *
165
+ * Supports:
166
+ * - Static segments: /about
167
+ * - Required params: /user/:id
168
+ * - Optional params: /user/:id?
169
+ * - Catch-all: /docs/:path(.*)
170
+ *
171
+ * @param {string} path - Route path pattern
172
+ * @returns {string} Regex pattern string
173
+ */
174
+ function buildRegexPattern(path) {
175
+ // Escape forward slashes
176
+ let pattern = path.replace(/\//g, '\\/')
177
+
178
+ // Replace catch-all :name(.*) → capture everything
179
+ pattern = pattern.replace(/:([^/(]+)\(\.\*\)/g, '([^/]+(?:/[^/]+)*)')
180
+
181
+ // Replace optional params :name? → optional capture
182
+ pattern = pattern.replace(/:([^/(]+)\?/g, '(?:\\/([^/]+))?')
183
+
184
+ // Replace required params :name → capture
185
+ pattern = pattern.replace(/:([^/(\s]+)/g, '([^/]+)')
186
+
187
+ return '^' + pattern + '$'
188
+ }
189
+
190
+ /**
191
+ * Matches a URL path against a route pattern.
192
+ *
193
+ * @param {string} pattern - Route pattern (e.g., '/user/:id')
194
+ * @param {string} path - URL path (e.g., '/user/123')
195
+ * @returns {object|null} Matched params or null
196
+ */
197
+ export function matchRoute(pattern, path) {
198
+ // Extract parameter names
199
+ const paramNames = []
200
+ const paramRegex = /:([^/?(]+)/g
201
+ let match
202
+ while ((match = paramRegex.exec(pattern)) !== null) {
203
+ paramNames.push(match[1])
204
+ }
205
+
206
+ // Build regex pattern
207
+ const regexPattern = buildRegexPattern(pattern)
208
+ const regex = new RegExp(regexPattern)
209
+
210
+ const matches = path.match(regex)
211
+ if (!matches) {
212
+ return null
213
+ }
214
+
215
+ // Extract parameter values
216
+ const params = {}
217
+ for (let i = 0; i < paramNames.length; i++) {
218
+ if (matches[i + 1] !== undefined) {
219
+ params[paramNames[i]] = matches[i + 1]
220
+ }
221
+ }
222
+
223
+ return { params, pattern }
224
+ }
225
+
226
+ /**
227
+ * Checks if a route path is dynamic.
228
+ *
229
+ * @param {string} path - Route path
230
+ * @returns {boolean}
231
+ */
232
+ export function isDynamicRoute(path) {
233
+ return path.includes(':')
22
234
  }
23
235
 
24
236
  /**
25
237
  * Extracts the page component from an eagerly-imported module.
26
- * Prefers default export, falls back to the first function export.
238
+ * Prefers default export, falls back to the first function/class export.
27
239
  *
28
240
  * @param {object} mod - Eagerly imported module
29
241
  * @param {string} key - Glob key (for error messages)
@@ -39,22 +251,216 @@ function componentFromModule(mod, key) {
39
251
  /**
40
252
  * Builds a metaowl route table from an import.meta.glob result.
41
253
  *
42
- * Usage in src/metaowl.js:
43
- *
44
- * import { boot } from 'metaowl'
45
- * boot(import.meta.glob('./pages/**\/*.js', { eager: true }))
46
- *
47
- * @param {Record<string, object>} modules - Result of import.meta.glob({ eager: true })
254
+ * @param {Record<string, object>} modules - Result of import.meta.glob with eager: true
48
255
  * @returns {object[]} Route table for processRoutes()
49
256
  */
50
257
  export function buildRoutes(modules) {
51
- return Object.entries(modules).map(([key, mod]) => {
258
+ const routes = []
259
+
260
+ for (const [key, mod] of Object.entries(modules)) {
52
261
  const path = pathFromKey(key)
53
- const name = path === '/' ? 'index' : path.slice(1).replace(/\//g, '-')
54
- return {
262
+ const name = path === '/' ? 'index' : path.slice(1).replace(/[^a-zA-Z0-9]/g, '-')
263
+ const component = componentFromModule(mod, key)
264
+ const params = extractParamNames(key)
265
+
266
+ const route = {
55
267
  name,
56
268
  path: [path],
57
- component: componentFromModule(mod, key)
269
+ component,
270
+ params,
271
+ meta: component.route?.meta || {}
58
272
  }
273
+
274
+ // Copy any route configuration from component
275
+ if (component.route) {
276
+ Object.assign(route, component.route)
277
+ }
278
+
279
+ routes.push(route)
280
+ }
281
+
282
+ // Sort routes: static routes first, then dynamic, then catch-all
283
+ routes.sort((a, b) => {
284
+ const aPath = a.path[0]
285
+ const bPath = b.path[0]
286
+
287
+ // Static routes come first
288
+ const aIsDynamic = isDynamicRoute(aPath)
289
+ const bIsDynamic = isDynamicRoute(bPath)
290
+
291
+ if (!aIsDynamic && bIsDynamic) return -1
292
+ if (aIsDynamic && !bIsDynamic) return 1
293
+
294
+ // Among dynamic routes, fewer params come first
295
+ if (aIsDynamic && bIsDynamic) {
296
+ const aParamCount = a.params?.length || 0
297
+ const bParamCount = b.params?.length || 0
298
+ return aParamCount - bParamCount
299
+ }
300
+
301
+ return 0
59
302
  })
303
+
304
+ return routes
305
+ }
306
+
307
+ /**
308
+ * Finds a matching route for a given URL path.
309
+ *
310
+ * @param {object[]} routes - Route table
311
+ * @param {string} path - URL path
312
+ * @returns {object|null} Matched route with params
313
+ */
314
+ export function findRoute(routes, path) {
315
+ for (const route of routes) {
316
+ for (const routePath of route.path) {
317
+ const match = matchRoute(routePath, path)
318
+ if (match) {
319
+ return {
320
+ ...route,
321
+ matchedPath: routePath,
322
+ params: match.params
323
+ }
324
+ }
325
+ }
326
+ }
327
+ return null
328
+ }
329
+
330
+ /**
331
+ * Generates URL from route name and params.
332
+ *
333
+ * @param {string} name - Route name
334
+ * @param {object} [params] - Route parameters
335
+ * @returns {string} Generated URL
336
+ * @throws {Error} If route not found
337
+ *
338
+ * Example:
339
+ * generateUrl(routes, 'user', { id: '123' }) // returns '/user/123'
340
+ * generateUrl(routes, 'blog-post', { category: 'tech', slug: 'hello' }) // returns '/blog/tech/hello'
341
+ */
342
+ export function generateUrl(routes, name, params = {}) {
343
+ const route = routes.find(r => r.name === name)
344
+ if (!route) {
345
+ throw new Error(`[metaowl] Route "${name}" not found`)
346
+ }
347
+
348
+ let path = route.path[0]
349
+
350
+ // Replace params in path
351
+ for (const [key, value] of Object.entries(params)) {
352
+ path = path.replace(`:${key}`, value)
353
+ path = path.replace(`:${key}?`, value)
354
+ }
355
+
356
+ // Remove remaining optional params
357
+ path = path.replace(/\/:[^/?]+\?/g, '')
358
+
359
+ return path
360
+ }
361
+
362
+ /**
363
+ * Validates route parameters.
364
+ *
365
+ * @param {object} route - Route definition
366
+ * @param {object} params - Parameters to validate
367
+ * @returns {object} Validation result { valid: boolean, missing: string[], extra: string[] }
368
+ */
369
+ export function validateRouteParams(route, params) {
370
+ const required = route.params || []
371
+ const provided = Object.keys(params)
372
+
373
+ const missing = required.filter(p => !provided.includes(p))
374
+ const extra = provided.filter(p => !required.includes(p))
375
+
376
+ return {
377
+ valid: missing.length === 0,
378
+ missing,
379
+ extra
380
+ }
381
+ }
382
+
383
+ /**
384
+ * Parses current URL and returns route info.
385
+ *
386
+ * @param {object[]} routes - Route table
387
+ * @returns {object|null} Current route info
388
+ */
389
+ export function parseCurrentRoute(routes) {
390
+ const path = document.location.pathname
391
+ return findRoute(routes, path)
392
+ }
393
+
394
+ /**
395
+ * Route configuration helper for components.
396
+ *
397
+ * @param {object} config - Route configuration
398
+ * @param {string} [config.path] - Route path override
399
+ * @param {object} [config.meta] - Route metadata
400
+ * @param {Function} [config.beforeEnter] - Per-route guard
401
+ * @returns {object} Route configuration
402
+ *
403
+ * Example in a component file:
404
+ * export class UserPage extends Component {
405
+ * static route = defineRoute({
406
+ * path: '/custom/:id',
407
+ * meta: { requiresAuth: true },
408
+ * beforeEnter: (to, from, next) => { ... }
409
+ * })
410
+ * }
411
+ */
412
+ export function defineRoute(config) {
413
+ return config
414
+ }
415
+
416
+ /**
417
+ * Route decorator (works with class decorator syntax).
418
+ *
419
+ * @param {object} config - Route configuration
420
+ * @returns {Function} Class decorator
421
+ *
422
+ * Example:
423
+ * @route({ meta: { requiresAuth: true } })
424
+ * export class UserPage extends Component {
425
+ * // ...
426
+ * }
427
+ */
428
+ export function route(config) {
429
+ return function decorator(ComponentClass) {
430
+ ComponentClass.route = config
431
+ return ComponentClass
432
+ }
433
+ }
434
+
435
+ /**
436
+ * Helper to create a catch-all route.
437
+ *
438
+ * @param {Function} component - 404 component
439
+ * @param {object} [options] - Additional options
440
+ * @returns {object} Catch-all route definition
441
+ */
442
+ export function createCatchAllRoute(component, options = {}) {
443
+ return {
444
+ name: options.name || '404',
445
+ path: ['/:path(.*)'],
446
+ component,
447
+ params: ['path'],
448
+ meta: { ...options.meta, catchAll: true }
449
+ }
450
+ }
451
+
452
+ /**
453
+ * Helper to create a redirect route.
454
+ *
455
+ * @param {string} from - From path
456
+ * @param {string} to - To path (can contain params)
457
+ * @returns {object} Redirect route definition
458
+ */
459
+ export function createRedirectRoute(from, to) {
460
+ return {
461
+ name: `redirect-${from.replace(/[^a-zA-Z0-9]/g, '-')}`,
462
+ path: [from],
463
+ redirect: to,
464
+ component: null
465
+ }
60
466
  }