metaowl 0.3.7 → 0.4.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 +192 -0
- package/index.js +118 -4
- package/modules/app-mounter.js +47 -4
- package/modules/link.js +255 -0
- package/modules/router.js +125 -13
- package/modules/templates-manager.js +46 -6
- package/package.json +1 -1
- package/test/link.test.js +189 -0
- package/test/templates-manager.test.js +28 -7
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
|
|
|
36
36
|
- [Dynamic Routes](#dynamic-routes)
|
|
37
37
|
- [Layouts](#layouts)
|
|
38
38
|
- [Navigation Guards](#navigation-guards)
|
|
39
|
+
- [Link Component](#link-component)
|
|
39
40
|
- [State Management](#state-management-store)
|
|
40
41
|
- [Error Boundaries](#error-boundaries)
|
|
41
42
|
- [i18n / Internationalization](#i18n--internationalization)
|
|
@@ -54,6 +55,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
|
|
|
54
55
|
- [Store](#store)
|
|
55
56
|
- [Layouts API](#layouts-api)
|
|
56
57
|
- [Router Guards](#router-guards-api)
|
|
58
|
+
- [Link Component API](#link-component-api)
|
|
57
59
|
- [Error Boundary](#error-boundary-api)
|
|
58
60
|
- [i18n](#i18n-api)
|
|
59
61
|
- [Forms](#forms-api)
|
|
@@ -77,6 +79,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
|
|
|
77
79
|
- **Dynamic routes** — support for parameters `[id]`, optional params `[id]?`, and catch-all `[...path]`
|
|
78
80
|
- **Layouts** — share page structures across routes with automatic layout resolution
|
|
79
81
|
- **Navigation guards** — route middleware for authentication, authorization, and redirects
|
|
82
|
+
- **SPA Link component** — `<Link>` for SPA navigation without page reloads and automatic external link detection
|
|
80
83
|
- **State management** — Pinia-like store system with mutations, actions, and getters
|
|
81
84
|
- **App mounting** — zero-config OWL component mounting with template merging
|
|
82
85
|
- **Fetch helper** — thin wrapper around the Fetch API with a configurable base URL and error handler
|
|
@@ -433,6 +436,139 @@ export class AdminPage extends Component {
|
|
|
433
436
|
|
|
434
437
|
---
|
|
435
438
|
|
|
439
|
+
## Link Component
|
|
440
|
+
|
|
441
|
+
The `Link` component provides SPA-style navigation without page reloads. It renders a standard `<a>` element and automatically handles internal navigation via `history.pushState`, while allowing normal browser behavior for external links.
|
|
442
|
+
|
|
443
|
+
### Setup
|
|
444
|
+
|
|
445
|
+
Import `Link` from `metaowl` and register it in your component's `static components`:
|
|
446
|
+
|
|
447
|
+
```js
|
|
448
|
+
import { Component } from '@odoo/owl'
|
|
449
|
+
import { Link } from 'metaowl'
|
|
450
|
+
|
|
451
|
+
export class MyNav extends Component {
|
|
452
|
+
static template = 'MyNav'
|
|
453
|
+
static components = { Link }
|
|
454
|
+
|
|
455
|
+
setup() {
|
|
456
|
+
this.linkClass = (href) => {
|
|
457
|
+
const base = 'block px-3 py-2 rounded-md text-sm'
|
|
458
|
+
const active = 'bg-gray-100 text-gray-900'
|
|
459
|
+
const inactive = 'text-gray-600 hover:bg-gray-100'
|
|
460
|
+
const isActive = window.location.pathname === href
|
|
461
|
+
return `${base} ${isActive ? active : inactive}`
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
### Basic Usage
|
|
468
|
+
|
|
469
|
+
Prop values are OWL expressions — wrap static strings in extra quotes, or pass method calls:
|
|
470
|
+
|
|
471
|
+
```xml
|
|
472
|
+
<!-- Internal link -->
|
|
473
|
+
<Link to="'/about'">About Us</Link>
|
|
474
|
+
|
|
475
|
+
<!-- Dynamic target from loop -->
|
|
476
|
+
<Link to="item.href"><t t-esc="item.label"/></Link>
|
|
477
|
+
|
|
478
|
+
<!-- Computed class via method -->
|
|
479
|
+
<Link to="item.href" class="linkClass(item.href)">
|
|
480
|
+
<t t-esc="item.label"/>
|
|
481
|
+
</Link>
|
|
482
|
+
|
|
483
|
+
<!-- External link — opens normally (auto-detected, no SPA intercept) -->
|
|
484
|
+
<Link to="'https://github.com/odoo/owl'" target="'_blank'">
|
|
485
|
+
OWL Framework
|
|
486
|
+
</Link>
|
|
487
|
+
|
|
488
|
+
<!-- External with dynamic target, closing sidebar on click -->
|
|
489
|
+
<Link
|
|
490
|
+
to="link.href"
|
|
491
|
+
target="link.external ? '_blank' : undefined"
|
|
492
|
+
t-on-click="props.onClose"
|
|
493
|
+
class="linkClass(link.href)"
|
|
494
|
+
>
|
|
495
|
+
<span t-esc="link.label"/>
|
|
496
|
+
</Link>
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
### Props
|
|
500
|
+
|
|
501
|
+
| Prop | Type | Required | Description |
|
|
502
|
+
|------|------|----------|-------------|
|
|
503
|
+
| `to` | `string` | Yes | Target URL (internal path or external URL) |
|
|
504
|
+
| `class` | `string` | No | CSS classes for the anchor element |
|
|
505
|
+
| `target` | `string` | No | Target window (`_blank`, `_self`, etc.) |
|
|
506
|
+
| `rel` | `string` | No | Relationship attribute (auto-set to `noopener noreferrer` for external `_blank` links) |
|
|
507
|
+
| `title` | `string` | No | Tooltip text |
|
|
508
|
+
| `download` | `string \| boolean` | No | Download attribute for file downloads |
|
|
509
|
+
| `hreflang` | `string` | No | Language of the linked resource |
|
|
510
|
+
| `type` | `string` | No | MIME type hint |
|
|
511
|
+
| `ping` | `string` | No | Space-separated URLs to ping on click |
|
|
512
|
+
| `referrerpolicy` | `string` | No | Referrer policy override |
|
|
513
|
+
| `media` | `string` | No | Media query hint |
|
|
514
|
+
|
|
515
|
+
Any additional attribute (`id`, `style`, `aria-*`, `data-*`, etc.) is forwarded directly to the rendered `<a>` element.
|
|
516
|
+
|
|
517
|
+
### External Link Detection
|
|
518
|
+
|
|
519
|
+
The component automatically detects external links and performs normal navigation:
|
|
520
|
+
|
|
521
|
+
- URLs starting with `http://` or `https://`
|
|
522
|
+
- Protocol-relative URLs (`//example.com`)
|
|
523
|
+
- Special protocols: `mailto:`, `tel:`, `ftp:`, etc.
|
|
524
|
+
|
|
525
|
+
### Programmatic Navigation
|
|
526
|
+
|
|
527
|
+
Use `navigateTo()` for programmatic navigation in JavaScript:
|
|
528
|
+
|
|
529
|
+
```js
|
|
530
|
+
import { navigateTo } from 'metaowl'
|
|
531
|
+
|
|
532
|
+
// Navigate to a new route
|
|
533
|
+
await navigateTo('/dashboard')
|
|
534
|
+
|
|
535
|
+
// Replace current history entry (no back button)
|
|
536
|
+
await navigateTo('/login', { replace: true })
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
### Router API
|
|
540
|
+
|
|
541
|
+
```js
|
|
542
|
+
import { router, navigateTo } from 'metaowl'
|
|
543
|
+
|
|
544
|
+
// Navigation
|
|
545
|
+
router.push('/path') // Navigate to path
|
|
546
|
+
router.replace('/path') // Replace current history entry
|
|
547
|
+
router.navigateTo('/path') // SPA navigation
|
|
548
|
+
router.back() // Go back
|
|
549
|
+
router.forward() // Go forward
|
|
550
|
+
router.go(-2) // Go 2 steps back
|
|
551
|
+
|
|
552
|
+
// Guards
|
|
553
|
+
router.beforeEach((to, from, next) => { ... })
|
|
554
|
+
router.afterEach((to, from) => { ... })
|
|
555
|
+
|
|
556
|
+
// State
|
|
557
|
+
router.currentRoute // Current route object
|
|
558
|
+
router.previousRoute // Previous route object
|
|
559
|
+
router.isNavigating // Boolean indicating navigation in progress
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
### SPA Mode
|
|
563
|
+
|
|
564
|
+
SPA navigation is enabled by default when using `boot()`. To disable:
|
|
565
|
+
|
|
566
|
+
```js
|
|
567
|
+
boot(routes, null, { spa: false })
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
---
|
|
571
|
+
|
|
436
572
|
## State Management (Store)
|
|
437
573
|
|
|
438
574
|
A Pinia-inspired store system with mutations, actions, and getters.
|
|
@@ -1012,6 +1148,56 @@ router.push('/new-path')
|
|
|
1012
1148
|
|
|
1013
1149
|
---
|
|
1014
1150
|
|
|
1151
|
+
### `Link Component API`
|
|
1152
|
+
|
|
1153
|
+
```xml
|
|
1154
|
+
<Link to="item.href" class="linkClass(item.href)" target="item.external ? '_blank' : undefined">
|
|
1155
|
+
<t t-esc="item.label"/>
|
|
1156
|
+
</Link>
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
| Prop | Type | Description |
|
|
1160
|
+
|------|------|-------------|
|
|
1161
|
+
| `to` | `string` | Target URL (required) |
|
|
1162
|
+
| `class` | `string` | CSS classes |
|
|
1163
|
+
| `target` | `string` | Target window (`_blank`, `_self`) |
|
|
1164
|
+
| `rel` | `string` | Link relationship (auto: `noopener noreferrer` for external `_blank`) |
|
|
1165
|
+
| `title` | `string` | Tooltip text |
|
|
1166
|
+
| `download` | `string \| boolean` | Download attribute |
|
|
1167
|
+
| `hreflang` | `string` | Language of the linked resource |
|
|
1168
|
+
| `type` | `string` | MIME type hint |
|
|
1169
|
+
| `ping` | `string` | URLs to ping on click |
|
|
1170
|
+
| `referrerpolicy` | `string` | Referrer policy override |
|
|
1171
|
+
| `media` | `string` | Media query hint |
|
|
1172
|
+
|
|
1173
|
+
All other attributes (`id`, `style`, `aria-*`, `data-*`, etc.) are forwarded to the `<a>` element.
|
|
1174
|
+
|
|
1175
|
+
**Programmatic Navigation:**
|
|
1176
|
+
|
|
1177
|
+
```js
|
|
1178
|
+
import { navigateTo, router } from 'metaowl'
|
|
1179
|
+
|
|
1180
|
+
// Navigate to new route (SPA mode)
|
|
1181
|
+
await navigateTo('/dashboard')
|
|
1182
|
+
|
|
1183
|
+
// Replace current history entry
|
|
1184
|
+
await navigateTo('/login', { replace: true })
|
|
1185
|
+
|
|
1186
|
+
// Using router singleton
|
|
1187
|
+
router.push('/path')
|
|
1188
|
+
router.replace('/path')
|
|
1189
|
+
router.back()
|
|
1190
|
+
router.forward()
|
|
1191
|
+
router.go(-2)
|
|
1192
|
+
```
|
|
1193
|
+
|
|
1194
|
+
**External Link Detection:**
|
|
1195
|
+
- `http://` or `https://` → Normal navigation
|
|
1196
|
+
- `//` → Protocol-relative, normal navigation
|
|
1197
|
+
- `mailto:`, `tel:`, `ftp:` → Normal navigation
|
|
1198
|
+
|
|
1199
|
+
---
|
|
1200
|
+
|
|
1015
1201
|
### `Error Boundary API`
|
|
1016
1202
|
|
|
1017
1203
|
| Function | Description |
|
|
@@ -1468,6 +1654,12 @@ npx serve -s dist
|
|
|
1468
1654
|
|
|
1469
1655
|
## Changelog
|
|
1470
1656
|
|
|
1657
|
+
### v0.4.0 (2026-03-24)
|
|
1658
|
+
|
|
1659
|
+
**Added:**
|
|
1660
|
+
|
|
1661
|
+
- **Link component** added.
|
|
1662
|
+
|
|
1471
1663
|
### v0.3.7 (2026-03-24)
|
|
1472
1664
|
|
|
1473
1665
|
**Fixed:**
|
package/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { mountApp } from './modules/app-mounter.js'
|
|
2
2
|
import { buildRoutes } from './modules/file-router.js'
|
|
3
|
-
import { processRoutes } from './modules/router.js'
|
|
3
|
+
import { processRoutes, setSpaMode, _setSpaNavigationCallback } from './modules/router.js'
|
|
4
4
|
import { discoverLayouts, buildLayouts, setDefaultLayout } from './modules/layouts.js'
|
|
5
5
|
|
|
6
6
|
export { default as Fetch } from './modules/fetch.js'
|
|
@@ -39,13 +39,17 @@ export {
|
|
|
39
39
|
isNavigating,
|
|
40
40
|
cancelNavigation,
|
|
41
41
|
navigate,
|
|
42
|
+
navigateTo,
|
|
42
43
|
push,
|
|
43
44
|
replace,
|
|
44
45
|
back,
|
|
45
46
|
forward,
|
|
46
47
|
go,
|
|
47
|
-
router
|
|
48
|
+
router,
|
|
49
|
+
setSpaMode,
|
|
50
|
+
isSpaMode
|
|
48
51
|
} from './modules/router.js'
|
|
52
|
+
export { Link, registerLinkTemplate } from './modules/link.js'
|
|
49
53
|
export {
|
|
50
54
|
matchRoute,
|
|
51
55
|
isDynamicRoute,
|
|
@@ -156,6 +160,91 @@ export {
|
|
|
156
160
|
PWA
|
|
157
161
|
} from './modules/pwa.js'
|
|
158
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Global routes reference for SPA navigation.
|
|
165
|
+
* @type {object[]|null}
|
|
166
|
+
*/
|
|
167
|
+
let _appRoutes = null
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Monotonically-increasing navigation counter.
|
|
171
|
+
* Incremented on every navigation attempt; lets us discard stale navigations
|
|
172
|
+
* that complete AFTER a newer one was already triggered.
|
|
173
|
+
* @type {number}
|
|
174
|
+
*/
|
|
175
|
+
let _navSeq = 0
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Promise of the currently-running mountApp call.
|
|
179
|
+
* Used to serialize mounts: a new navigation waits for the in-progress mount
|
|
180
|
+
* to finish, then checks if it is still the latest before mounting itself.
|
|
181
|
+
* This prevents concurrent OWL App instances on the same element.
|
|
182
|
+
* @type {Promise<void>|null}
|
|
183
|
+
*/
|
|
184
|
+
let _mountingPromise = null
|
|
185
|
+
|
|
186
|
+
function _handle404() {
|
|
187
|
+
const el = document.getElementById('metaowl')
|
|
188
|
+
if (el) {
|
|
189
|
+
el.innerHTML = [
|
|
190
|
+
'<div style="font-family:sans-serif;padding:3rem;text-align:center">',
|
|
191
|
+
'<h1 style="font-size:4rem;font-weight:700;margin:0;color:#6b7280">404</h1>',
|
|
192
|
+
'<p style="font-size:1.25rem;color:#9ca3af;margin-top:0.5rem">Page not found</p>',
|
|
193
|
+
'<p style="margin-top:2rem"><a href="/" style="color:#3b82f6;text-decoration:none">← Go home</a></p>',
|
|
194
|
+
'</div>'
|
|
195
|
+
].join('')
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* SPA navigation callback.
|
|
201
|
+
* Called when navigateTo() is used.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} path - The target path
|
|
204
|
+
* @returns {Promise<void>}
|
|
205
|
+
*/
|
|
206
|
+
async function _spaNavigate(path) {
|
|
207
|
+
if (!_appRoutes) {
|
|
208
|
+
console.error('[metaowl] Routes not available for SPA navigation')
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const seq = ++_navSeq
|
|
213
|
+
|
|
214
|
+
let route
|
|
215
|
+
try {
|
|
216
|
+
route = await processRoutes(_appRoutes, path)
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (seq !== _navSeq) return
|
|
219
|
+
if (error.message && error.message.startsWith('No route found')) {
|
|
220
|
+
console.warn('[metaowl]', error.message)
|
|
221
|
+
_handle404()
|
|
222
|
+
} else {
|
|
223
|
+
throw error
|
|
224
|
+
}
|
|
225
|
+
return
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Bail early if a newer navigation overtook us while processRoutes was running
|
|
229
|
+
if (seq !== _navSeq || !route) return
|
|
230
|
+
|
|
231
|
+
// Wait for any in-progress mount to finish before starting our own.
|
|
232
|
+
// This is the key serialization: it ensures only one OWL App mounts at a time.
|
|
233
|
+
if (_mountingPromise) {
|
|
234
|
+
await _mountingPromise.catch(() => {})
|
|
235
|
+
// After waiting, check again — a newer navigation may have started
|
|
236
|
+
if (seq !== _navSeq) return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Claim the mount slot
|
|
240
|
+
_mountingPromise = mountApp(route)
|
|
241
|
+
try {
|
|
242
|
+
await _mountingPromise
|
|
243
|
+
} finally {
|
|
244
|
+
_mountingPromise = null
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
159
248
|
/**
|
|
160
249
|
* Boots the metaowl application.
|
|
161
250
|
*
|
|
@@ -170,14 +259,21 @@ export {
|
|
|
170
259
|
* boot([{ name: 'index', path: ['/'], component: IndexPage }])
|
|
171
260
|
*
|
|
172
261
|
* @param {Record<string, object>|object[]} [routesOrModules]
|
|
262
|
+
* @param {object} [options] - Boot options
|
|
263
|
+
* @param {boolean} [options.spa=true] - Enable SPA navigation mode
|
|
173
264
|
*/
|
|
174
|
-
export async function boot(routesOrModules = {}, layoutsOrModules = null) {
|
|
265
|
+
export async function boot(routesOrModules = {}, layoutsOrModules = null, options = {}) {
|
|
266
|
+
const { spa = true } = options
|
|
267
|
+
|
|
175
268
|
// Auto-discover layouts
|
|
176
269
|
try {
|
|
177
|
-
if (layoutsOrModules) {
|
|
270
|
+
if (layoutsOrModules && typeof layoutsOrModules === 'object' && !Array.isArray(layoutsOrModules)) {
|
|
178
271
|
// Use layouts provided by Vite plugin transformation
|
|
179
272
|
buildLayouts(layoutsOrModules)
|
|
180
273
|
setDefaultLayout('default')
|
|
274
|
+
} else if (typeof layoutsOrModules === 'object' && layoutsOrModules?.spa !== undefined) {
|
|
275
|
+
// Options object passed as second argument
|
|
276
|
+
Object.assign(options, layoutsOrModules)
|
|
181
277
|
} else {
|
|
182
278
|
await discoverLayouts()
|
|
183
279
|
}
|
|
@@ -189,6 +285,24 @@ export async function boot(routesOrModules = {}, layoutsOrModules = null) {
|
|
|
189
285
|
? routesOrModules
|
|
190
286
|
: buildRoutes(routesOrModules)
|
|
191
287
|
|
|
288
|
+
// Store routes for SPA navigation
|
|
289
|
+
_appRoutes = routes
|
|
290
|
+
|
|
291
|
+
// Enable SPA mode
|
|
292
|
+
if (spa) {
|
|
293
|
+
setSpaMode(true)
|
|
294
|
+
_setSpaNavigationCallback(_spaNavigate)
|
|
295
|
+
|
|
296
|
+
// Register global navigateTo handler for Link component
|
|
297
|
+
window.__metaowlNavigate = _spaNavigate
|
|
298
|
+
|
|
299
|
+
// Listen to PopState events (Browser Back/Forward)
|
|
300
|
+
window.addEventListener('popstate', (event) => {
|
|
301
|
+
const path = document.location.pathname
|
|
302
|
+
_spaNavigate(path)
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
|
|
192
306
|
let route
|
|
193
307
|
try {
|
|
194
308
|
route = await processRoutes(routes)
|
package/modules/app-mounter.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import { mount } from '@odoo/owl'
|
|
9
9
|
import { mergeTemplates } from './templates-manager.js'
|
|
10
10
|
import { resolveLayout, getLayout, mountWithLayout } from './layouts.js'
|
|
11
|
+
import { Link } from './link.js'
|
|
11
12
|
|
|
12
13
|
const _defaults = {
|
|
13
14
|
warnIfNoStaticProps: true,
|
|
@@ -17,6 +18,13 @@ const _defaults = {
|
|
|
17
18
|
|
|
18
19
|
let _config = { ..._defaults }
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Reference to the currently mounted OWL App instance.
|
|
23
|
+
* Destroyed before each new mount to prevent zombie app accumulation.
|
|
24
|
+
* @type {import('@odoo/owl').App|null}
|
|
25
|
+
*/
|
|
26
|
+
let _currentApp = null
|
|
27
|
+
|
|
20
28
|
/**
|
|
21
29
|
* Override or extend the default OWL mount configuration.
|
|
22
30
|
* Call before boot() in your project's metaowl.js.
|
|
@@ -37,11 +45,31 @@ export function configureOwl(config) {
|
|
|
37
45
|
* @param {object[]} route - Single-element array returned by `processRoutes()`.
|
|
38
46
|
* @returns {Promise<void>}
|
|
39
47
|
*/
|
|
48
|
+
/**
|
|
49
|
+
* Cached merged templates string. Computed once on first navigation;
|
|
50
|
+
* COMPONENTS (the list of XML files) never changes at runtime so the
|
|
51
|
+
* result is the same for every mount.
|
|
52
|
+
* @type {string|null}
|
|
53
|
+
*/
|
|
54
|
+
let _cachedTemplates = null
|
|
55
|
+
|
|
40
56
|
export async function mountApp(route) {
|
|
41
|
-
//
|
|
57
|
+
// Load and cache templates on first call; reuse on every subsequent navigation.
|
|
58
|
+
// Without caching, every navigation re-fetches all XML template files.
|
|
42
59
|
const components = typeof COMPONENTS !== 'undefined' ? COMPONENTS : []
|
|
43
|
-
|
|
60
|
+
if (!_cachedTemplates) {
|
|
61
|
+
_cachedTemplates = await mergeTemplates(components)
|
|
62
|
+
}
|
|
63
|
+
const templates = _cachedTemplates
|
|
44
64
|
const mountElement = document.getElementById('metaowl')
|
|
65
|
+
|
|
66
|
+
// Destroy the previous OWL App before mounting a new one.
|
|
67
|
+
// Without this, every navigation leaves a zombie app running in the background
|
|
68
|
+
// (scheduler, reactive effects, event listeners) that accumulates and causes freezes.
|
|
69
|
+
if (_currentApp) {
|
|
70
|
+
try { _currentApp.destroy() } catch (_) {}
|
|
71
|
+
_currentApp = null
|
|
72
|
+
}
|
|
45
73
|
mountElement.innerHTML = ''
|
|
46
74
|
|
|
47
75
|
const pageComponent = route[0].component
|
|
@@ -51,11 +79,26 @@ export async function mountApp(route) {
|
|
|
51
79
|
const layoutName = resolveLayout(pageComponent, pagePath)
|
|
52
80
|
const LayoutClass = getLayout(layoutName)
|
|
53
81
|
|
|
82
|
+
// Base mount configuration with built-in components
|
|
83
|
+
const baseConfig = {
|
|
84
|
+
..._config,
|
|
85
|
+
templates,
|
|
86
|
+
components: {
|
|
87
|
+
Link,
|
|
88
|
+
't-link': Link
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let instance
|
|
54
93
|
if (LayoutClass) {
|
|
55
94
|
// Mount with layout
|
|
56
|
-
await mountWithLayout(pageComponent, mountElement, { routePath: pagePath,
|
|
95
|
+
instance = await mountWithLayout(pageComponent, mountElement, { routePath: pagePath, ...baseConfig })
|
|
57
96
|
} else {
|
|
58
97
|
// Mount without layout
|
|
59
|
-
await mount(pageComponent, mountElement,
|
|
98
|
+
instance = await mount(pageComponent, mountElement, baseConfig)
|
|
60
99
|
}
|
|
100
|
+
|
|
101
|
+
// Store OWL App reference so we can destroy it before the next navigation.
|
|
102
|
+
// instance.__owl__.app is the underlying App object that owns the scheduler.
|
|
103
|
+
_currentApp = instance?.__owl__?.app ?? null
|
|
61
104
|
}
|
package/modules/link.js
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Link
|
|
3
|
+
*
|
|
4
|
+
* SPA Link component for metaowl with automatic external link detection.
|
|
5
|
+
*
|
|
6
|
+
* This component renders a link that navigates via history.pushState
|
|
7
|
+
* for internal targets (without page reload) and navigates normally
|
|
8
|
+
* for external targets.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Automatic detection of external links (http://, https://, //, mailto:, tel:, etc.)
|
|
12
|
+
* - SPA navigation for internal links (no page reload)
|
|
13
|
+
* - Support for active link styling
|
|
14
|
+
* - Respects modifier keys (Ctrl, Meta, Alt) for normal browser navigation
|
|
15
|
+
* - Accessible links with correct href attributes
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // Internal link
|
|
19
|
+
* <t-link to="/about">About Us</t-link>
|
|
20
|
+
*
|
|
21
|
+
* // With CSS classes
|
|
22
|
+
* <t-link to="/user/profile" class="btn btn-primary">Profile</t-link>
|
|
23
|
+
*
|
|
24
|
+
* // Active link styling
|
|
25
|
+
* <t-link to="/about" activeClass="active">About Us</t-link>
|
|
26
|
+
*
|
|
27
|
+
* // External link (automatically detected)
|
|
28
|
+
* <t-link to="https://example.com">External</t-link>
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Component, useState, onMounted, onWillUnmount } from '@odoo/owl'
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Regex for detecting external URLs.
|
|
35
|
+
* Matches: http://, https://, // (protocol-relative), mailto:, tel:, ftp:, etc.
|
|
36
|
+
* @type {RegExp}
|
|
37
|
+
*/
|
|
38
|
+
const EXTERNAL_URL_REGEX = /^(https?:|\/\/|mailto:|tel:|ftp:|file:|javascript:)/i
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Checks if a URL is external.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} url - The URL to check
|
|
44
|
+
* @returns {boolean} True if external
|
|
45
|
+
*/
|
|
46
|
+
function isExternalUrl(url) {
|
|
47
|
+
if (!url || typeof url !== 'string') return false
|
|
48
|
+
return EXTERNAL_URL_REGEX.test(url)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Checks if a link is considered "active" (for styling).
|
|
53
|
+
*
|
|
54
|
+
* @param {string} linkPath - The link path
|
|
55
|
+
* @param {string} currentPath - The current path
|
|
56
|
+
* @returns {boolean} True if active
|
|
57
|
+
*/
|
|
58
|
+
function isActiveLink(linkPath, currentPath) {
|
|
59
|
+
if (!linkPath || !currentPath) return false
|
|
60
|
+
// Exact match or subpath
|
|
61
|
+
const normalizedLink = linkPath.replace(/\/$/, '') || '/'
|
|
62
|
+
const normalizedCurrent = currentPath.replace(/\/$/, '') || '/'
|
|
63
|
+
return normalizedCurrent === normalizedLink ||
|
|
64
|
+
(normalizedLink !== '/' && normalizedCurrent.startsWith(normalizedLink + '/'))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Link component for SPA navigation.
|
|
69
|
+
*
|
|
70
|
+
* Renders an <a> element that performs internal navigation
|
|
71
|
+
* without page reload.
|
|
72
|
+
*/
|
|
73
|
+
export class Link extends Component {
|
|
74
|
+
static template = 'Link'
|
|
75
|
+
static props = {
|
|
76
|
+
to: { type: String, optional: false },
|
|
77
|
+
class: { type: String, optional: true },
|
|
78
|
+
activeClass: { type: String, optional: true },
|
|
79
|
+
target: { type: String, optional: true },
|
|
80
|
+
rel: { type: String, optional: true },
|
|
81
|
+
title: { type: String, optional: true },
|
|
82
|
+
download: { type: [String, Boolean], optional: true },
|
|
83
|
+
hreflang: { type: String, optional: true },
|
|
84
|
+
type: { type: String, optional: true },
|
|
85
|
+
ping: { type: String, optional: true },
|
|
86
|
+
referrerpolicy: { type: String, optional: true },
|
|
87
|
+
media: { type: String, optional: true },
|
|
88
|
+
'*': true,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
setup() {
|
|
92
|
+
this.state = useState({
|
|
93
|
+
isActive: false
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// Reference to navigation function (injected from outside)
|
|
97
|
+
this._navigate = null
|
|
98
|
+
|
|
99
|
+
onMounted(() => {
|
|
100
|
+
this._updateActiveState()
|
|
101
|
+
// Listen to PopState events for updating active status
|
|
102
|
+
window.addEventListener('popstate', this._updateActiveState)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
onWillUnmount(() => {
|
|
106
|
+
window.removeEventListener('popstate', this._updateActiveState)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
this._updateActiveState = () => {
|
|
110
|
+
if (this.props.activeClass) {
|
|
111
|
+
this.state.isActive = isActiveLink(this.props.to, document.location.pathname)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Getter for combined CSS classes.
|
|
118
|
+
* @returns {string}
|
|
119
|
+
*/
|
|
120
|
+
get linkClasses() {
|
|
121
|
+
const classes = []
|
|
122
|
+
if (this.props.class) {
|
|
123
|
+
classes.push(this.props.class)
|
|
124
|
+
}
|
|
125
|
+
if (this.state.isActive && this.props.activeClass) {
|
|
126
|
+
classes.push(this.props.activeClass)
|
|
127
|
+
}
|
|
128
|
+
return classes.join(' ')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Getter for the rel attribute.
|
|
133
|
+
* Automatically adds noopener noreferrer for external links with target="_blank".
|
|
134
|
+
* @returns {string|undefined}
|
|
135
|
+
*/
|
|
136
|
+
get linkRel() {
|
|
137
|
+
if (this.props.rel) return this.props.rel
|
|
138
|
+
if (isExternalUrl(this.props.to) && this.props.target === '_blank') {
|
|
139
|
+
return 'noopener noreferrer'
|
|
140
|
+
}
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Forward unknown component props as native <a> attributes.
|
|
146
|
+
* This allows id/style/aria-* / data-* and similar anchor attributes.
|
|
147
|
+
* @returns {Record<string, any>}
|
|
148
|
+
*/
|
|
149
|
+
get forwardedAttrs() {
|
|
150
|
+
const attrs = { ...this.props }
|
|
151
|
+
delete attrs.to
|
|
152
|
+
delete attrs.class
|
|
153
|
+
delete attrs.activeClass
|
|
154
|
+
delete attrs.target
|
|
155
|
+
delete attrs.rel
|
|
156
|
+
delete attrs.title
|
|
157
|
+
delete attrs.download
|
|
158
|
+
return attrs
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Handler for click events.
|
|
163
|
+
* Checks if SPA navigation is possible or normal navigation should be used.
|
|
164
|
+
*
|
|
165
|
+
* @param {MouseEvent} ev - The click event
|
|
166
|
+
*/
|
|
167
|
+
onClick(ev) {
|
|
168
|
+
const url = this.props.to
|
|
169
|
+
|
|
170
|
+
// External URLs: Normal navigation
|
|
171
|
+
if (isExternalUrl(url)) {
|
|
172
|
+
return // Let browser handle normal navigation
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Modifier keys: Normal navigation (new tab, download, etc.)
|
|
176
|
+
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
|
|
177
|
+
return
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Right click: Context menu
|
|
181
|
+
if (ev.button !== 0) {
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Download link: Normal navigation
|
|
186
|
+
if (this.props.download) {
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Internal SPA navigation
|
|
191
|
+
ev.preventDefault()
|
|
192
|
+
|
|
193
|
+
// Update URL immediately so components reading window.location.pathname
|
|
194
|
+
// (e.g. isActive in sidebar) already see the correct path when the new
|
|
195
|
+
// app mounts. The generation counter in _spaNavigate guarantees that only
|
|
196
|
+
// the last-triggered navigation actually calls mountApp.
|
|
197
|
+
window.history.pushState({ path: url }, '', url)
|
|
198
|
+
|
|
199
|
+
if (typeof window.__metaowlNavigate === 'function') {
|
|
200
|
+
window.__metaowlNavigate(url)
|
|
201
|
+
} else {
|
|
202
|
+
// Fallback: normal navigation (URL already updated above)
|
|
203
|
+
window.location.href = url
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Template for the Link component.
|
|
210
|
+
* Must be registered in the app's templates file or loaded dynamically.
|
|
211
|
+
*/
|
|
212
|
+
export const LinkTemplate = /* xml */ `
|
|
213
|
+
<templates>
|
|
214
|
+
<t t-name="Link">
|
|
215
|
+
<a
|
|
216
|
+
t-att="forwardedAttrs"
|
|
217
|
+
t-att-href="props.to"
|
|
218
|
+
t-att-class="linkClasses"
|
|
219
|
+
t-att-target="props.target"
|
|
220
|
+
t-att-rel="linkRel"
|
|
221
|
+
t-att-title="props.title"
|
|
222
|
+
t-att-download="props.download"
|
|
223
|
+
t-on-click="onClick"
|
|
224
|
+
>
|
|
225
|
+
<t t-slot="default"/>
|
|
226
|
+
</a>
|
|
227
|
+
</t>
|
|
228
|
+
</templates>
|
|
229
|
+
`
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Helper function to register the Link template.
|
|
233
|
+
* Called automatically on app startup.
|
|
234
|
+
*
|
|
235
|
+
* @param {object} templates - The app's templates object
|
|
236
|
+
* @returns {string|void} Modified templates if string was passed
|
|
237
|
+
*/
|
|
238
|
+
export function registerLinkTemplate(templates) {
|
|
239
|
+
if (typeof templates === 'string') {
|
|
240
|
+
// If templates is a string, add the Link template
|
|
241
|
+
// Remove outer <templates> tags from LinkTemplate
|
|
242
|
+
const linkContent = LinkTemplate
|
|
243
|
+
.replace('<templates>', '')
|
|
244
|
+
.replace('</templates>', '')
|
|
245
|
+
.trim()
|
|
246
|
+
// Insert before closing </templates> tag
|
|
247
|
+
return templates.replace('</templates>', linkContent + '\n</templates>')
|
|
248
|
+
}
|
|
249
|
+
// If templates is an object, register the template
|
|
250
|
+
if (templates && typeof templates === 'object') {
|
|
251
|
+
templates.Link = LinkTemplate
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export default Link
|
package/modules/router.js
CHANGED
|
@@ -173,7 +173,7 @@ class Router {
|
|
|
173
173
|
let pattern = routePath
|
|
174
174
|
// Escape forward slashes
|
|
175
175
|
.replace(/\//g, '\\/')
|
|
176
|
-
// Replace catch-all :name(.*) params
|
|
176
|
+
// Replace catch-all :name(.*) params - must come before required-param replacement
|
|
177
177
|
.replace(/:([^/(]+)\(\.\*\)/g, '(.*)')
|
|
178
178
|
// Replace optional params /:name?
|
|
179
179
|
.replace(/\/:([^/(]+)\?/g, '(?:/([^/]+))?')
|
|
@@ -273,16 +273,28 @@ class Router {
|
|
|
273
273
|
* @returns {Promise<object[]>} Resolved route or throws error
|
|
274
274
|
* @throws {NavigationError} If navigation is aborted
|
|
275
275
|
*/
|
|
276
|
+
// Tracks which routes arrays have already been augmented with SSG path variants.
|
|
277
|
+
// Using a WeakSet means each distinct array is injected exactly once, even
|
|
278
|
+
// across multiple processRoutes calls with the same array.
|
|
279
|
+
const _injectedRouteSets = new WeakSet()
|
|
280
|
+
|
|
276
281
|
export async function processRoutes(routes, customPath) {
|
|
277
282
|
// Use custom path for testing if provided
|
|
278
283
|
const targetPath = customPath || document.location.pathname
|
|
279
284
|
|
|
280
|
-
// Inject SSG-compatible path variants
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
285
|
+
// Inject SSG-compatible path variants ONCE per routes array.
|
|
286
|
+
// injectSystemRoutes mutates route.path in-place. Calling it on every
|
|
287
|
+
// navigation causes the arrays to grow on every call (each injected path
|
|
288
|
+
// becomes a base for further injections), making route matching
|
|
289
|
+
// exponentially slower with every navigation.
|
|
290
|
+
if (!_injectedRouteSets.has(routes)) {
|
|
291
|
+
_injectedRouteSets.add(routes)
|
|
292
|
+
for (const route of routes) {
|
|
293
|
+
const originalPaths = [...route.path]
|
|
294
|
+
for (const path of originalPaths) {
|
|
295
|
+
if (typeof path === 'string') {
|
|
296
|
+
injectSystemRoutes(route, path)
|
|
297
|
+
}
|
|
286
298
|
}
|
|
287
299
|
}
|
|
288
300
|
}
|
|
@@ -559,6 +571,96 @@ export function cancelNavigation() {
|
|
|
559
571
|
}
|
|
560
572
|
}
|
|
561
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Callback for SPA navigation.
|
|
576
|
+
* Set when the app is initialized.
|
|
577
|
+
* @type {Function|null}
|
|
578
|
+
*/
|
|
579
|
+
let _spaNavigationCallback = null
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Sets the SPA navigation callback.
|
|
583
|
+
* Called internally by boot().
|
|
584
|
+
*
|
|
585
|
+
* @param {Function} callback - Function called during SPA navigation
|
|
586
|
+
* @internal
|
|
587
|
+
*/
|
|
588
|
+
export function _setSpaNavigationCallback(callback) {
|
|
589
|
+
_spaNavigationCallback = callback
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Flag indicating if SPA navigation is enabled.
|
|
594
|
+
* @type {boolean}
|
|
595
|
+
*/
|
|
596
|
+
let _spaEnabled = false
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Enables or disables SPA navigation.
|
|
600
|
+
*
|
|
601
|
+
* @param {boolean} enabled - True to enable SPA navigation
|
|
602
|
+
*/
|
|
603
|
+
export function setSpaMode(enabled) {
|
|
604
|
+
_spaEnabled = enabled
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* Checks if SPA navigation is enabled.
|
|
609
|
+
*
|
|
610
|
+
* @returns {boolean}
|
|
611
|
+
*/
|
|
612
|
+
export function isSpaMode() {
|
|
613
|
+
return _spaEnabled
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* Navigate to a path with SPA navigation (no page reload).
|
|
618
|
+
* Updates URL via history.pushState and renders the new route.
|
|
619
|
+
*
|
|
620
|
+
* @param {string} path - Target path (e.g., "/about")
|
|
621
|
+
* @param {object} [options] - Navigation options
|
|
622
|
+
* @param {boolean} [options.replace=false] - Replace current history entry instead of creating new one
|
|
623
|
+
* @returns {Promise<boolean>} True if navigation successful
|
|
624
|
+
*
|
|
625
|
+
* @example
|
|
626
|
+
* // Normal navigation
|
|
627
|
+
* await navigateTo('/about')
|
|
628
|
+
*
|
|
629
|
+
* // Replace current entry (no back possible)
|
|
630
|
+
* await navigateTo('/login', { replace: true })
|
|
631
|
+
*/
|
|
632
|
+
export async function navigateTo(path, options = {}) {
|
|
633
|
+
const { replace = false } = options
|
|
634
|
+
|
|
635
|
+
if (!_spaEnabled || !_spaNavigationCallback) {
|
|
636
|
+
// Fallback: Normal browser navigation
|
|
637
|
+
if (replace) {
|
|
638
|
+
window.location.replace(path)
|
|
639
|
+
} else {
|
|
640
|
+
window.location.href = path
|
|
641
|
+
}
|
|
642
|
+
return false
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
try {
|
|
646
|
+
// Update URL without page reload
|
|
647
|
+
if (replace) {
|
|
648
|
+
window.history.replaceState({ path }, '', path)
|
|
649
|
+
} else {
|
|
650
|
+
window.history.pushState({ path }, '', path)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Perform SPA navigation
|
|
654
|
+
await _spaNavigationCallback(path)
|
|
655
|
+
return true
|
|
656
|
+
} catch (error) {
|
|
657
|
+
console.error('[metaowl] SPA navigation failed:', error)
|
|
658
|
+
// Fallback to normal navigation on error
|
|
659
|
+
window.location.href = path
|
|
660
|
+
return false
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
562
664
|
/**
|
|
563
665
|
* Programmatically navigate to a path.
|
|
564
666
|
*
|
|
@@ -566,14 +668,21 @@ export function cancelNavigation() {
|
|
|
566
668
|
* @param {object} [options] - Navigation options
|
|
567
669
|
* @param {boolean} [options.replace=false] - Replace current history entry
|
|
568
670
|
* @param {boolean} [options.reload=true] - Reload the page
|
|
671
|
+
* @deprecated Use navigateTo() for SPA navigation
|
|
569
672
|
*/
|
|
570
673
|
export function navigate(path, options = {}) {
|
|
571
674
|
const { replace = false, reload = true } = options
|
|
572
675
|
|
|
573
|
-
if (
|
|
574
|
-
|
|
676
|
+
if (reload || !_spaEnabled) {
|
|
677
|
+
// Traditional navigation with page reload
|
|
678
|
+
if (replace) {
|
|
679
|
+
window.location.replace(path)
|
|
680
|
+
} else {
|
|
681
|
+
window.location.href = path
|
|
682
|
+
}
|
|
575
683
|
} else {
|
|
576
|
-
|
|
684
|
+
// SPA navigation
|
|
685
|
+
navigateTo(path, { replace })
|
|
577
686
|
}
|
|
578
687
|
}
|
|
579
688
|
|
|
@@ -583,7 +692,7 @@ export function navigate(path, options = {}) {
|
|
|
583
692
|
* @param {string} path - Target path
|
|
584
693
|
*/
|
|
585
694
|
export function push(path) {
|
|
586
|
-
|
|
695
|
+
navigateTo(path, { replace: false })
|
|
587
696
|
}
|
|
588
697
|
|
|
589
698
|
/**
|
|
@@ -592,7 +701,7 @@ export function push(path) {
|
|
|
592
701
|
* @param {string} path - Target path
|
|
593
702
|
*/
|
|
594
703
|
export function replace(path) {
|
|
595
|
-
|
|
704
|
+
navigateTo(path, { replace: true })
|
|
596
705
|
}
|
|
597
706
|
|
|
598
707
|
/**
|
|
@@ -633,7 +742,10 @@ export const router = {
|
|
|
633
742
|
back,
|
|
634
743
|
forward,
|
|
635
744
|
go,
|
|
636
|
-
navigate
|
|
745
|
+
navigate,
|
|
746
|
+
navigateTo,
|
|
747
|
+
setSpaMode,
|
|
748
|
+
isSpaMode
|
|
637
749
|
}
|
|
638
750
|
|
|
639
751
|
/**
|
|
@@ -5,6 +5,36 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { loadFile } from '@odoo/owl'
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Link component template.
|
|
10
|
+
* Automatically added to all templates.
|
|
11
|
+
* @type {string}
|
|
12
|
+
*/
|
|
13
|
+
const LINK_COMPONENT_TEMPLATE = /* xml */ `
|
|
14
|
+
<t t-name="Link">
|
|
15
|
+
<a
|
|
16
|
+
t-att="forwardedAttrs"
|
|
17
|
+
t-att-href="props.to"
|
|
18
|
+
t-att-class="linkClasses"
|
|
19
|
+
t-att-target="props.target"
|
|
20
|
+
t-att-rel="linkRel"
|
|
21
|
+
t-att-title="props.title"
|
|
22
|
+
t-att-download="props.download"
|
|
23
|
+
t-on-click="onClick"
|
|
24
|
+
>
|
|
25
|
+
<t t-slot="default"/>
|
|
26
|
+
</a>
|
|
27
|
+
</t>
|
|
28
|
+
`
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Internal templates that are automatically added.
|
|
32
|
+
* @type {string[]}
|
|
33
|
+
*/
|
|
34
|
+
const INTERNAL_TEMPLATES = [
|
|
35
|
+
LINK_COMPONENT_TEMPLATE
|
|
36
|
+
]
|
|
37
|
+
|
|
8
38
|
/**
|
|
9
39
|
* Loads OWL XML template(s) into a string ready to be passed to OWL's mount() options.
|
|
10
40
|
*
|
|
@@ -22,15 +52,15 @@ export async function mergeTemplates(files) {
|
|
|
22
52
|
if (fileArray.length === 1) {
|
|
23
53
|
try {
|
|
24
54
|
const content = await loadFile(fileArray[0])
|
|
25
|
-
// If already wrapped (merged templates.xml), return as-is
|
|
55
|
+
// If already wrapped (merged templates.xml), return as-is with internal templates
|
|
26
56
|
if (content.trim().startsWith('<templates>')) {
|
|
27
|
-
return content
|
|
57
|
+
return content.replace('</templates>', INTERNAL_TEMPLATES.join('') + '</templates>')
|
|
28
58
|
}
|
|
29
|
-
// Otherwise wrap it
|
|
30
|
-
return '<templates>' + content + '</templates>'
|
|
59
|
+
// Otherwise wrap it with internal templates
|
|
60
|
+
return '<templates>' + content + INTERNAL_TEMPLATES.join('') + '</templates>'
|
|
31
61
|
} catch (e) {
|
|
32
62
|
console.error(`[metaowl] Failed to load template: ${fileArray[0]}`, e)
|
|
33
|
-
return '<templates
|
|
63
|
+
return '<templates>' + INTERNAL_TEMPLATES.join('') + '</templates>'
|
|
34
64
|
}
|
|
35
65
|
}
|
|
36
66
|
|
|
@@ -45,5 +75,15 @@ export async function mergeTemplates(files) {
|
|
|
45
75
|
}
|
|
46
76
|
})
|
|
47
77
|
)
|
|
48
|
-
return '<templates>' + results.join('') + '</templates>'
|
|
78
|
+
return '<templates>' + results.join('') + INTERNAL_TEMPLATES.join('') + '</templates>'
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Gibt die internen Templates zurück.
|
|
83
|
+
* Nützlich für Testing oder manuelle Template-Registrierung.
|
|
84
|
+
*
|
|
85
|
+
* @returns {string[]}
|
|
86
|
+
*/
|
|
87
|
+
export function getInternalTemplates() {
|
|
88
|
+
return [...INTERNAL_TEMPLATES]
|
|
49
89
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metaowl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
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",
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Link Tests
|
|
3
|
+
*
|
|
4
|
+
* Tests for the Link component and SPA navigation.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
7
|
+
import { Link, registerLinkTemplate } from '../modules/link.js'
|
|
8
|
+
import {
|
|
9
|
+
navigateTo,
|
|
10
|
+
setSpaMode,
|
|
11
|
+
isSpaMode,
|
|
12
|
+
_setSpaNavigationCallback,
|
|
13
|
+
resetRouter
|
|
14
|
+
} from '../modules/router.js'
|
|
15
|
+
|
|
16
|
+
// Mock für window
|
|
17
|
+
const mockPushState = vi.fn()
|
|
18
|
+
const mockReplaceState = vi.fn()
|
|
19
|
+
const mockHistoryBack = vi.fn()
|
|
20
|
+
const mockHistoryForward = vi.fn()
|
|
21
|
+
const mockHistoryGo = vi.fn()
|
|
22
|
+
const mockAddEventListener = vi.fn()
|
|
23
|
+
const mockRemoveEventListener = vi.fn()
|
|
24
|
+
|
|
25
|
+
// Setup global mocks
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.resetAllMocks()
|
|
28
|
+
resetRouter()
|
|
29
|
+
|
|
30
|
+
// Mock window.location
|
|
31
|
+
Object.defineProperty(globalThis, 'window', {
|
|
32
|
+
value: {
|
|
33
|
+
location: {
|
|
34
|
+
pathname: '/',
|
|
35
|
+
href: 'http://localhost/',
|
|
36
|
+
replace: vi.fn()
|
|
37
|
+
},
|
|
38
|
+
history: {
|
|
39
|
+
pushState: mockPushState,
|
|
40
|
+
replaceState: mockReplaceState,
|
|
41
|
+
back: mockHistoryBack,
|
|
42
|
+
forward: mockHistoryForward,
|
|
43
|
+
go: mockHistoryGo
|
|
44
|
+
},
|
|
45
|
+
addEventListener: mockAddEventListener,
|
|
46
|
+
removeEventListener: mockRemoveEventListener
|
|
47
|
+
},
|
|
48
|
+
writable: true,
|
|
49
|
+
configurable: true
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// Mock document.location
|
|
53
|
+
Object.defineProperty(globalThis, 'document', {
|
|
54
|
+
value: {
|
|
55
|
+
location: {
|
|
56
|
+
pathname: '/'
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
writable: true,
|
|
60
|
+
configurable: true
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('Link Component', () => {
|
|
65
|
+
describe('isExternalUrl', () => {
|
|
66
|
+
it('should return false for internal paths', () => {
|
|
67
|
+
const internalPaths = ['/', '/about', '/user/123', '/blog/post-slug']
|
|
68
|
+
|
|
69
|
+
for (const path of internalPaths) {
|
|
70
|
+
// Test durch Instanziierung der Komponente und Prüfung des Verhaltens
|
|
71
|
+
const link = new Link()
|
|
72
|
+
expect(link).toBeDefined()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('should return true for external URLs', () => {
|
|
77
|
+
const externalUrls = [
|
|
78
|
+
'http://example.com',
|
|
79
|
+
'https://example.com',
|
|
80
|
+
'//example.com',
|
|
81
|
+
'mailto:test@example.com',
|
|
82
|
+
'tel:+1234567890',
|
|
83
|
+
'ftp://ftp.example.com',
|
|
84
|
+
'javascript:void(0)'
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
for (const url of externalUrls) {
|
|
88
|
+
const link = new Link()
|
|
89
|
+
expect(link).toBeDefined()
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
describe('Link props', () => {
|
|
95
|
+
it('should accept required "to" prop', () => {
|
|
96
|
+
const link = new Link()
|
|
97
|
+
expect(Link.props.to.optional).toBe(false)
|
|
98
|
+
expect(Link.props.to.type).toBe(String)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('should accept optional props', () => {
|
|
102
|
+
expect(Link.props.class.optional).toBe(true)
|
|
103
|
+
expect(Link.props.activeClass.optional).toBe(true)
|
|
104
|
+
expect(Link.props.target.optional).toBe(true)
|
|
105
|
+
expect(Link.props.rel.optional).toBe(true)
|
|
106
|
+
expect(Link.props.title.optional).toBe(true)
|
|
107
|
+
expect(Link.props.download.optional).toBe(true)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('should have correct static template', () => {
|
|
111
|
+
expect(Link.template).toBe('Link')
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('registerLinkTemplate', () => {
|
|
116
|
+
it('should add Link template to string templates', () => {
|
|
117
|
+
const templates = '<templates><t t-name="Test"></t></templates>'
|
|
118
|
+
const result = registerLinkTemplate(templates)
|
|
119
|
+
|
|
120
|
+
expect(result).toContain('t-name="Link"')
|
|
121
|
+
expect(result).toContain('<a')
|
|
122
|
+
expect(result).toContain('</templates>')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('should add Link template to object templates', () => {
|
|
126
|
+
const templates = { Test: '<t t-name="Test"></t>' }
|
|
127
|
+
registerLinkTemplate(templates)
|
|
128
|
+
|
|
129
|
+
expect(templates.Link).toBeDefined()
|
|
130
|
+
expect(templates.Link).toContain('t-name="Link"')
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
describe('SPA Navigation', () => {
|
|
136
|
+
describe('navigateTo', () => {
|
|
137
|
+
it('should use window.location when SPA mode is disabled', async () => {
|
|
138
|
+
setSpaMode(false)
|
|
139
|
+
const locationHrefSpy = vi.spyOn(window.location, 'href', 'set')
|
|
140
|
+
|
|
141
|
+
await navigateTo('/about')
|
|
142
|
+
|
|
143
|
+
expect(locationHrefSpy).toHaveBeenCalledWith('/about')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('should use history.pushState when SPA mode is enabled', async () => {
|
|
147
|
+
setSpaMode(true)
|
|
148
|
+
const mockCallback = vi.fn().mockResolvedValue(undefined)
|
|
149
|
+
_setSpaNavigationCallback(mockCallback)
|
|
150
|
+
|
|
151
|
+
await navigateTo('/about')
|
|
152
|
+
|
|
153
|
+
expect(mockPushState).toHaveBeenCalledWith({ path: '/about' }, '', '/about')
|
|
154
|
+
expect(mockCallback).toHaveBeenCalledWith('/about')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('should use history.replaceState when replace option is true', async () => {
|
|
158
|
+
setSpaMode(true)
|
|
159
|
+
const mockCallback = vi.fn().mockResolvedValue(undefined)
|
|
160
|
+
_setSpaNavigationCallback(mockCallback)
|
|
161
|
+
|
|
162
|
+
await navigateTo('/about', { replace: true })
|
|
163
|
+
|
|
164
|
+
expect(mockReplaceState).toHaveBeenCalledWith({ path: '/about' }, '', '/about')
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('should fallback to window.location on navigation error', async () => {
|
|
168
|
+
setSpaMode(true)
|
|
169
|
+
const mockCallback = vi.fn().mockRejectedValue(new Error('Navigation failed'))
|
|
170
|
+
_setSpaNavigationCallback(mockCallback)
|
|
171
|
+
|
|
172
|
+
const locationHrefSpy = vi.spyOn(window.location, 'href', 'set')
|
|
173
|
+
|
|
174
|
+
await navigateTo('/about')
|
|
175
|
+
|
|
176
|
+
expect(locationHrefSpy).toHaveBeenCalledWith('/about')
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('setSpaMode / isSpaMode', () => {
|
|
181
|
+
it('should enable and disable SPA mode', () => {
|
|
182
|
+
setSpaMode(true)
|
|
183
|
+
expect(isSpaMode()).toBe(true)
|
|
184
|
+
|
|
185
|
+
setSpaMode(false)
|
|
186
|
+
expect(isSpaMode()).toBe(false)
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
-
import { mergeTemplates } from '../modules/templates-manager.js'
|
|
2
|
+
import { mergeTemplates, getInternalTemplates } from '../modules/templates-manager.js'
|
|
3
3
|
|
|
4
4
|
vi.mock('@odoo/owl', () => ({
|
|
5
5
|
loadFile: vi.fn(),
|
|
@@ -15,7 +15,10 @@ describe('mergeTemplates', () => {
|
|
|
15
15
|
it('returns wrapped templates string from a single file', async () => {
|
|
16
16
|
loadFile.mockResolvedValue('<t t-name="Comp"><div/></t>')
|
|
17
17
|
const result = await mergeTemplates(['/components/Comp.xml'])
|
|
18
|
-
expect(result).
|
|
18
|
+
expect(result).toContain('<templates>')
|
|
19
|
+
expect(result).toContain('<t t-name="Comp"><div/></t>')
|
|
20
|
+
expect(result).toContain('</templates>')
|
|
21
|
+
expect(result).toContain('t-name="Link"') // Internal Link template
|
|
19
22
|
})
|
|
20
23
|
|
|
21
24
|
it('concatenates multiple template files', async () => {
|
|
@@ -23,19 +26,27 @@ describe('mergeTemplates', () => {
|
|
|
23
26
|
.mockResolvedValueOnce('<t t-name="A"><div/></t>')
|
|
24
27
|
.mockResolvedValueOnce('<t t-name="B"><span/></t>')
|
|
25
28
|
const result = await mergeTemplates(['/A.xml', '/B.xml'])
|
|
26
|
-
expect(result).
|
|
29
|
+
expect(result).toContain('<templates>')
|
|
30
|
+
expect(result).toContain('<t t-name="A"><div/></t>')
|
|
31
|
+
expect(result).toContain('<t t-name="B"><span/></t>')
|
|
32
|
+
expect(result).toContain('</templates>')
|
|
33
|
+
expect(result).toContain('t-name="Link"') // Internal Link template
|
|
27
34
|
})
|
|
28
35
|
|
|
29
|
-
it('returns
|
|
36
|
+
it('returns templates with internal components for empty array', async () => {
|
|
30
37
|
const result = await mergeTemplates([])
|
|
31
|
-
expect(result).
|
|
38
|
+
expect(result).toContain('<templates>')
|
|
39
|
+
expect(result).toContain('</templates>')
|
|
40
|
+
expect(result).toContain('t-name="Link"') // Internal Link template
|
|
32
41
|
})
|
|
33
42
|
|
|
34
43
|
it('skips failed files and logs error', async () => {
|
|
35
44
|
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
36
45
|
loadFile.mockRejectedValue(new Error('404'))
|
|
37
46
|
const result = await mergeTemplates(['/missing.xml'])
|
|
38
|
-
expect(result).
|
|
47
|
+
expect(result).toContain('<templates>')
|
|
48
|
+
expect(result).toContain('</templates>')
|
|
49
|
+
expect(result).toContain('t-name="Link"') // Internal Link template despite error
|
|
39
50
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
40
51
|
expect.stringContaining('[metaowl] Failed to load template: /missing.xml'),
|
|
41
52
|
expect.any(Error)
|
|
@@ -50,10 +61,20 @@ describe('mergeTemplates', () => {
|
|
|
50
61
|
.mockRejectedValueOnce(new Error('404'))
|
|
51
62
|
.mockResolvedValueOnce('<t t-name="Also"><span/></t>')
|
|
52
63
|
const result = await mergeTemplates(['/good.xml', '/missing.xml', '/also.xml'])
|
|
53
|
-
expect(result).
|
|
64
|
+
expect(result).toContain('<t t-name="Good"><div/></t>')
|
|
65
|
+
expect(result).toContain('<t t-name="Also"><span/></t>')
|
|
66
|
+
expect(result).toContain('t-name="Link"') // Internal Link template
|
|
54
67
|
consoleSpy.mockRestore()
|
|
55
68
|
})
|
|
56
69
|
|
|
70
|
+
it('includes internal Link component template', async () => {
|
|
71
|
+
const templates = getInternalTemplates()
|
|
72
|
+
expect(templates.length).toBeGreaterThan(0)
|
|
73
|
+
expect(templates[0]).toContain('t-name="Link"')
|
|
74
|
+
expect(templates[0]).toContain('<a')
|
|
75
|
+
expect(templates[0]).toContain('t-att-href')
|
|
76
|
+
})
|
|
77
|
+
|
|
57
78
|
it('calls loadFile once per path', async () => {
|
|
58
79
|
loadFile.mockResolvedValue('<t/>')
|
|
59
80
|
await mergeTemplates(['/a.xml', '/b.xml', '/c.xml'])
|