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.
- package/README.md +853 -10
- package/bin/metaowl-create.js +431 -9
- package/index.js +155 -1
- package/modules/app-mounter.js +7 -0
- package/modules/auto-import.js +225 -0
- package/modules/cache.js +2 -0
- package/modules/composables.js +600 -0
- package/modules/error-boundary.js +228 -0
- package/modules/fetch.js +7 -0
- package/modules/file-router.js +425 -19
- package/modules/forms.js +353 -0
- package/modules/i18n.js +333 -0
- package/modules/layouts.js +433 -0
- package/modules/odoo-rpc.js +511 -0
- package/modules/pwa.js +515 -0
- package/modules/router.js +593 -29
- package/modules/seo.js +501 -0
- package/modules/store.js +409 -0
- package/modules/templates-manager.js +5 -0
- package/modules/test-utils.js +532 -0
- package/package.json +1 -1
- package/test/auto-import.test.js +110 -0
- package/test/composables.test.js +103 -0
- package/test/dynamic-routes.test.js +520 -0
- package/test/error-boundary.test.js +126 -0
- package/test/forms.test.js +203 -0
- package/test/i18n.test.js +188 -0
- package/test/layouts.test.js +395 -0
- package/test/odoo-rpc.test.js +547 -0
- package/test/pwa.test.js +154 -0
- package/test/router-guards.test.js +617 -0
- package/test/seo.test.js +353 -0
- package/test/store.test.js +476 -0
- package/test/test-utils.test.js +314 -0
- package/vite/plugin.js +43 -5
|
@@ -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
|
package/modules/file-router.js
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
//
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
54
|
-
|
|
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
|
|
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
|
}
|