metaowl 0.3.4 → 0.3.7

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
@@ -4,10 +4,12 @@
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/metaowl.svg)](https://www.npmjs.com/package/metaowl)
6
6
  [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](LICENSE)
7
- [![Node.js >=18](https://img.shields.io/badge/node-%3E%3D18-brightgreen.svg)](https://nodejs.org)
7
+ [![Node.js >=20](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
8
8
  [![GitHub Issues](https://img.shields.io/github/issues/dennisschott/metaowl.svg)](https://github.com/dennisschott/metaowl/issues)
9
9
 
10
- metaowl is a complete solution for building production-ready OWL applications with everything you need out of the box:
10
+ > ⚠️ **Work in progress:** metaowl is not production-ready yet. APIs may change without notice, and features may still break between releases.
11
+
12
+ metaowl is a complete solution for building OWL applications with everything you need out of the box:
11
13
 
12
14
  **Core Infrastructure:** File-based routing with dynamic routes, layout system, navigation guards, Pinia-inspired state management, and zero-config app mounting.
13
15
 
@@ -99,7 +101,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
99
101
 
100
102
  | Dependency | Version |
101
103
  |---|---|
102
- | Node.js | `>=18` |
104
+ | Node.js | `>=20` |
103
105
  | `@odoo/owl` | bundled |
104
106
 
105
107
  ---
@@ -267,7 +269,7 @@ File-based routing supports dynamic segments using bracket notation. The router
267
269
  | `pages/product/[category]/[slug]/Product.js` | `/product/:category/:slug` | `/product/tech/hello` | `{ category: 'tech', slug: 'hello' }` |
268
270
  | `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
271
  | `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' }` |
272
+ | `pages/[...path]/NotFound.js` | `/:path(.*)` | `/any/unknown/path` | `{ path: 'any/unknown/path' }` |
271
273
 
272
274
  **Param Types:**
273
275
 
@@ -275,25 +277,55 @@ File-based routing supports dynamic segments using bracket notation. The router
275
277
  - `[param?]` — Optional parameter, may be omitted
276
278
  - `[...param]` — Catch-all parameter, matches any number of segments
277
279
 
278
- Access parameters in your component:
280
+ Access parameters in your component via `getCurrentRoute()`:
279
281
 
280
282
  ```js
281
- import { Component, xml } from '@odoo/owl'
283
+ import { Component } from '@odoo/owl'
284
+ import { getCurrentRoute } from 'metaowl'
282
285
 
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']
286
+ export default class UserPage extends Component {
287
+ static template = 'UserPage'
288
+
289
+ setup() {
290
+ const route = getCurrentRoute()
291
+ this.id = route?.params?.id
292
+ }
292
293
  }
293
294
  ```
294
295
 
295
296
  ---
296
297
 
298
+ ### 404 / Not Found Pages
299
+
300
+ Create a catch-all route at `pages/[...path]/` to handle unknown URLs:
301
+
302
+ ```
303
+ src/pages/
304
+ [...path]/
305
+ NotFound.js ← rendered for any unmatched URL
306
+ NotFound.xml
307
+ ```
308
+
309
+ ```js
310
+ // pages/[...path]/NotFound.js
311
+ import { Component } from '@odoo/owl'
312
+ import { Meta } from 'metaowl'
313
+
314
+ export default class NotFound extends Component {
315
+ static template = 'NotFound'
316
+
317
+ setup() {
318
+ Meta.title('404 – Page Not Found')
319
+ }
320
+ }
321
+ ```
322
+
323
+ 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.
324
+
325
+ 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.
326
+
327
+ ---
328
+
297
329
  ## Layouts
298
330
 
299
331
  Layouts provide shared page structures. Create a `layouts/` directory alongside your `pages/`:
@@ -1434,6 +1466,20 @@ npx serve -s dist
1434
1466
 
1435
1467
  ---
1436
1468
 
1469
+ ## Changelog
1470
+
1471
+ ### v0.3.7 (2026-03-24)
1472
+
1473
+ **Fixed:**
1474
+
1475
+ - **bin/metaowl-lint.js**: Fixed inconsistent default lint paths. Changed from `src/owl/pages/**` and `src/owl/components/**` to `src/pages/**` and `src/components/**` to match the documented project structure.
1476
+
1477
+ - **eslint.js**: Fixed `ignores` configuration placement. Moved `ignores` to a separate configuration object as required by ESLint Flat Config format. Also added `.metaowl/**` to the ignore list for the auto-generated component declarations.
1478
+
1479
+ - **modules/auto-import.js**: Fixed missing `node:` prefix for Node.js built-in module import.
1480
+
1481
+ ---
1482
+
1437
1483
  ## Contributing
1438
1484
 
1439
1485
  Contributions are welcome! Please open an issue before submitting a pull request so we can discuss the change.
@@ -27,8 +27,8 @@ try {
27
27
  const defaults = [
28
28
  'src/metaowl.js',
29
29
  'src/css.js',
30
- 'src/owl/pages/**',
31
- 'src/owl/components/**'
30
+ 'src/pages/**',
31
+ 'src/components/**'
32
32
  ]
33
33
 
34
34
  const candidates = lintTargets ?? defaults
package/eslint.js CHANGED
@@ -39,11 +39,14 @@ export const eslintConfig = [
39
39
  'quotes': ['error', 'single'],
40
40
  'comma-dangle': ['error', 'never'],
41
41
  'no-undef': 'off'
42
- },
42
+ }
43
+ },
44
+ {
43
45
  ignores: [
44
46
  'node_modules/**',
45
47
  'dist/**',
46
- 'build/**'
48
+ 'build/**',
49
+ '.metaowl/**'
47
50
  ]
48
51
  }
49
52
  ]
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
  }
@@ -23,7 +23,7 @@
23
23
  */
24
24
 
25
25
  import { globSync } from 'glob'
26
- import { resolve, relative, basename, extname, dirname } from 'path'
26
+ import { resolve, relative, basename, extname, dirname } from 'node:path'
27
27
 
28
28
  /**
29
29
  * Registry of auto-discovered components.
@@ -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.4",
3
+ "version": "0.3.7",
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')