metaowl 0.3.3 → 0.3.5

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 CHANGED
@@ -267,7 +267,7 @@ File-based routing supports dynamic segments using bracket notation. The router
267
267
  | `pages/product/[category]/[slug]/Product.js` | `/product/:category/:slug` | `/product/tech/hello` | `{ category: 'tech', slug: 'hello' }` |
268
268
  | `pages/blog/[id]/[slug?]/Blog.js` | `/blog/:id/:slug?` | `/blog/123` or `/blog/123/my-post` | `{ id: '123' }` or `{ id: '123', slug: 'my-post' }` |
269
269
  | `pages/docs/[...path]/Docs.js` | `/docs/:path(.*)` | `/docs/api/routing` | `{ path: 'api/routing' }` |
270
- | `pages/[...404]/NotFound.js` | `/:path(.*)` | `/any/unknown/path` | `{ path: 'any/unknown/path' }` |
270
+ | `pages/[...path]/NotFound.js` | `/:path(.*)` | `/any/unknown/path` | `{ path: 'any/unknown/path' }` |
271
271
 
272
272
  **Param Types:**
273
273
 
@@ -275,23 +275,53 @@ File-based routing supports dynamic segments using bracket notation. The router
275
275
  - `[param?]` — Optional parameter, may be omitted
276
276
  - `[...param]` — Catch-all parameter, matches any number of segments
277
277
 
278
- Access parameters in your component:
278
+ Access parameters in your component via `getCurrentRoute()`:
279
279
 
280
280
  ```js
281
- import { Component, xml } from '@odoo/owl'
281
+ import { Component } from '@odoo/owl'
282
+ import { getCurrentRoute } from 'metaowl'
282
283
 
283
- export class UserPage extends Component {
284
- static template = xml`
285
- <div>
286
- <h1>User Profile</h1>
287
- <p>ID: <t t-esc="props.params.id"/></p>
288
- </div>
289
- `
290
-
291
- static props = ['params']
284
+ export default class UserPage extends Component {
285
+ static template = 'UserPage'
286
+
287
+ setup() {
288
+ const route = getCurrentRoute()
289
+ this.id = route?.params?.id
290
+ }
291
+ }
292
+ ```
293
+
294
+ ---
295
+
296
+ ### 404 / Not Found Pages
297
+
298
+ Create a catch-all route at `pages/[...path]/` to handle unknown URLs:
299
+
300
+ ```
301
+ src/pages/
302
+ [...path]/
303
+ NotFound.js ← rendered for any unmatched URL
304
+ NotFound.xml
305
+ ```
306
+
307
+ ```js
308
+ // pages/[...path]/NotFound.js
309
+ import { Component } from '@odoo/owl'
310
+ import { Meta } from 'metaowl'
311
+
312
+ export default class NotFound extends Component {
313
+ static template = 'NotFound'
314
+
315
+ setup() {
316
+ Meta.title('404 – Page Not Found')
317
+ }
292
318
  }
293
319
  ```
294
320
 
321
+ The catch-all directory can be named `[...path]`, `[...404]`, or any `[...name]` — the bracket-dot-dot-dot prefix is what makes it a catch-all regardless of name.
322
+
323
+ If no catch-all route exists and a URL cannot be matched, metaowl renders a minimal built-in 404 message so the page doesn't silently break.
324
+
295
325
  ---
296
326
 
297
327
  ## Layouts
package/index.js CHANGED
@@ -188,6 +188,27 @@ export async function boot(routesOrModules = {}, layoutsOrModules = null) {
188
188
  const routes = Array.isArray(routesOrModules)
189
189
  ? routesOrModules
190
190
  : buildRoutes(routesOrModules)
191
- const route = await processRoutes(routes)
191
+
192
+ let route
193
+ try {
194
+ route = await processRoutes(routes)
195
+ } catch (error) {
196
+ if (error.message && error.message.startsWith('No route found')) {
197
+ console.warn('[metaowl]', error.message)
198
+ const el = document.getElementById('metaowl')
199
+ if (el) {
200
+ el.innerHTML = [
201
+ '<div style="font-family:sans-serif;padding:3rem;text-align:center">',
202
+ '<h1 style="font-size:4rem;font-weight:700;margin:0;color:#6b7280">404</h1>',
203
+ '<p style="font-size:1.25rem;color:#9ca3af;margin-top:0.5rem">Page not found</p>',
204
+ '<p style="margin-top:2rem"><a href="/" style="color:#3b82f6;text-decoration:none">← Go home</a></p>',
205
+ '</div>'
206
+ ].join('')
207
+ }
208
+ return
209
+ }
210
+ throw error
211
+ }
212
+
192
213
  await mountApp(route)
193
214
  }
@@ -279,20 +279,30 @@ export function buildRoutes(modules) {
279
279
  routes.push(route)
280
280
  }
281
281
 
282
- // Sort routes: static routes first, then dynamic, then catch-all
282
+ // Sort routes: static first, then dynamic (fewer params first), catch-all last
283
283
  routes.sort((a, b) => {
284
284
  const aPath = a.path[0]
285
285
  const bPath = b.path[0]
286
286
 
287
- // Static routes come first
287
+ // Catch-all :name(.*) routes always come last
288
+ const aIsCatchAll = aPath.includes('(.*)')
289
+ const bIsCatchAll = bPath.includes('(.*)')
290
+ if (!aIsCatchAll && bIsCatchAll) return -1
291
+ if (aIsCatchAll && !bIsCatchAll) return 1
292
+
293
+ // Static routes before dynamic routes
288
294
  const aIsDynamic = isDynamicRoute(aPath)
289
295
  const bIsDynamic = isDynamicRoute(bPath)
290
296
 
291
297
  if (!aIsDynamic && bIsDynamic) return -1
292
298
  if (aIsDynamic && !bIsDynamic) return 1
293
299
 
294
- // Among dynamic routes, fewer params come first
300
+ // Among dynamic routes, more specific (longer path) comes first
295
301
  if (aIsDynamic && bIsDynamic) {
302
+ const aSegments = aPath.split('/').length
303
+ const bSegments = bPath.split('/').length
304
+ if (aSegments !== bSegments) return bSegments - aSegments
305
+
296
306
  const aParamCount = a.params?.length || 0
297
307
  const bParamCount = b.params?.length || 0
298
308
  return aParamCount - bParamCount
package/modules/router.js CHANGED
@@ -162,24 +162,25 @@ class Router {
162
162
  * @returns {boolean}
163
163
  */
164
164
  pathMatches(routePath, currentPath) {
165
- // Convert route pattern to regex
165
+ // Simple exact match for static routes
166
166
  if (!routePath.includes(':') && !routePath.includes('*')) {
167
- // Simple exact match
168
167
  const normalizedRoute = routePath.replace(/\/$/, '') || '/'
169
168
  const normalizedCurrent = currentPath.replace(/\/$/, '') || '/'
170
169
  return normalizedRoute === normalizedCurrent
171
170
  }
172
171
 
173
- // Dynamic route matching
172
+ // Build regex without pre-escaping the whole string (which would break : and * handling)
174
173
  let pattern = routePath
175
- // Escape special regex characters except pattern markers
176
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
177
- // Replace optional params :param?
178
- .replace(/\\:([^/]+)\\?/g, '(?:\\/([^/]+))?')
179
- // Replace required params :param
180
- .replace(/\\:([^/]+)/g, '([^/]+)')
181
- // Replace wildcards
182
- .replace(/\\\*/g, '(.*)')
174
+ // Escape forward slashes
175
+ .replace(/\//g, '\\/')
176
+ // Replace catch-all :name(.*) params — must come before required-param replacement
177
+ .replace(/:([^/(]+)\(\.\*\)/g, '(.*)')
178
+ // Replace optional params /:name?
179
+ .replace(/\/:([^/(]+)\?/g, '(?:/([^/]+))?')
180
+ // Replace required params :name
181
+ .replace(/:([^/(?\s]+)/g, '([^/]+)')
182
+ // Replace bare wildcards *
183
+ .replace(/\*/g, '(.*)')
183
184
 
184
185
  pattern = '^' + pattern + '$'
185
186
  const regex = new RegExp(pattern)
@@ -219,12 +220,23 @@ class Router {
219
220
  return null
220
221
  }
221
222
 
222
- // Extract parameter names
223
+ // Extract parameter names in the correct order
223
224
  const paramNames = []
224
- const pattern = routePath.replace(/:([^/?]+)\??/g, (match, name) => {
225
- paramNames.push(name)
226
- return '([^/]+)'
227
- })
225
+
226
+ // Build pattern: handle catch-all :name(.*) first, then optional, then required
227
+ let pattern = routePath
228
+ .replace(/:([^/(]+)\(\.\*\)/g, (match, name) => {
229
+ paramNames.push(name)
230
+ return '(.*)'
231
+ })
232
+ .replace(/\/:([^/(]+)\?/g, (match, name) => {
233
+ paramNames.push(name)
234
+ return '(?:/([^/]+))?'
235
+ })
236
+ .replace(/:([^/(?\s]+)/g, (match, name) => {
237
+ paramNames.push(name)
238
+ return '([^/]+)'
239
+ })
228
240
 
229
241
  const regex = new RegExp('^' + pattern + '$')
230
242
  const matches = currentPath.match(regex)
@@ -233,7 +245,9 @@ class Router {
233
245
 
234
246
  const params = {}
235
247
  for (let i = 0; i < paramNames.length; i++) {
236
- params[paramNames[i]] = matches[i + 1]
248
+ if (matches[i + 1] !== undefined) {
249
+ params[paramNames[i]] = matches[i + 1]
250
+ }
237
251
  }
238
252
 
239
253
  return params
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metaowl",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Lightweight meta-framework for Odoo OWL — file-based routing, app mounting, Fetch helper, Cache, Meta tags, SSG generator, and a Vite plugin.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -393,7 +393,7 @@ describe('Dynamic Routes', () => {
393
393
 
394
394
  // Check that routes are sorted correctly
395
395
  const blogRoute = routes.find(r => r.name === 'blog')
396
- const categoryRoute = routes.find(r => r.name.includes('category'))
396
+ const categoryRoute = routes.find(r => r.name.includes('category') && !r.name.includes('slug'))
397
397
  const postRoute = routes.find(r => r.name.includes('slug'))
398
398
 
399
399
  expect(blogRoute.path[0]).toBe('/blog')
package/vite/plugin.js CHANGED
@@ -28,7 +28,7 @@ function collectXml(globPattern) {
28
28
  /**
29
29
  * Merge all XML template files into a single XML string.
30
30
  * Removes <templates> wrappers from individual files and wraps everything in a single <templates>.
31
- * The result is minified to reduce file size.
31
+ * Templates are concatenated without minification to preserve intentional line breaks.
32
32
  *
33
33
  * @param {string[]} xmlPaths - Array of absolute file paths to XML files
34
34
  * @returns {string} Merged XML string
@@ -46,10 +46,7 @@ function mergeXmlFiles(xmlPaths) {
46
46
  }
47
47
  }).join('')
48
48
 
49
- // Minify: remove unnecessary whitespace while keeping valid XML structure
50
- const minified = '<templates>' + templates.replace(/\s+/g, ' ').replace(/>\s+</g, '><').trim() + '</templates>'
51
-
52
- return minified
49
+ return '<templates>' + templates + '</templates>'
53
50
  }
54
51
 
55
52
  /**