metaowl 0.3.5 → 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 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
 
@@ -34,6 +36,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
34
36
  - [Dynamic Routes](#dynamic-routes)
35
37
  - [Layouts](#layouts)
36
38
  - [Navigation Guards](#navigation-guards)
39
+ - [Link Component](#link-component)
37
40
  - [State Management](#state-management-store)
38
41
  - [Error Boundaries](#error-boundaries)
39
42
  - [i18n / Internationalization](#i18n--internationalization)
@@ -52,6 +55,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
52
55
  - [Store](#store)
53
56
  - [Layouts API](#layouts-api)
54
57
  - [Router Guards](#router-guards-api)
58
+ - [Link Component API](#link-component-api)
55
59
  - [Error Boundary](#error-boundary-api)
56
60
  - [i18n](#i18n-api)
57
61
  - [Forms](#forms-api)
@@ -75,6 +79,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
75
79
  - **Dynamic routes** — support for parameters `[id]`, optional params `[id]?`, and catch-all `[...path]`
76
80
  - **Layouts** — share page structures across routes with automatic layout resolution
77
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
78
83
  - **State management** — Pinia-like store system with mutations, actions, and getters
79
84
  - **App mounting** — zero-config OWL component mounting with template merging
80
85
  - **Fetch helper** — thin wrapper around the Fetch API with a configurable base URL and error handler
@@ -99,7 +104,7 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
99
104
 
100
105
  | Dependency | Version |
101
106
  |---|---|
102
- | Node.js | `>=18` |
107
+ | Node.js | `>=20` |
103
108
  | `@odoo/owl` | bundled |
104
109
 
105
110
  ---
@@ -431,6 +436,139 @@ export class AdminPage extends Component {
431
436
 
432
437
  ---
433
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
+
434
572
  ## State Management (Store)
435
573
 
436
574
  A Pinia-inspired store system with mutations, actions, and getters.
@@ -1010,6 +1148,56 @@ router.push('/new-path')
1010
1148
 
1011
1149
  ---
1012
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
+
1013
1201
  ### `Error Boundary API`
1014
1202
 
1015
1203
  | Function | Description |
@@ -1464,6 +1652,26 @@ npx serve -s dist
1464
1652
 
1465
1653
  ---
1466
1654
 
1655
+ ## Changelog
1656
+
1657
+ ### v0.4.0 (2026-03-24)
1658
+
1659
+ **Added:**
1660
+
1661
+ - **Link component** added.
1662
+
1663
+ ### v0.3.7 (2026-03-24)
1664
+
1665
+ **Fixed:**
1666
+
1667
+ - **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.
1668
+
1669
+ - **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.
1670
+
1671
+ - **modules/auto-import.js**: Fixed missing `node:` prefix for Node.js built-in module import.
1672
+
1673
+ ---
1674
+
1467
1675
  ## Contributing
1468
1676
 
1469
1677
  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
@@ -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)
@@ -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
- // COMPONENTS is a string[] injected at build time by the metaowl Vite plugin
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
- const templates = await mergeTemplates(components)
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, templates })
95
+ instance = await mountWithLayout(pageComponent, mountElement, { routePath: pagePath, ...baseConfig })
57
96
  } else {
58
97
  // Mount without layout
59
- await mount(pageComponent, mountElement, { ..._config, templates })
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
  }
@@ -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.
@@ -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 must come before required-param replacement
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
- for (const route of routes) {
282
- const originalPaths = [...route.path]
283
- for (const path of originalPaths) {
284
- if (typeof path === 'string') {
285
- injectSystemRoutes(route, path)
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 (replace) {
574
- window.location.replace(path)
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
- window.location.href = path
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
- navigate(path, { replace: false })
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
- navigate(path, { replace: true })
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></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.5",
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).toBe('<templates><t t-name="Comp"><div/></t></templates>')
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).toBe('<templates><t t-name="A"><div/></t><t t-name="B"><span/></t></templates>')
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 empty templates tag for empty array', async () => {
36
+ it('returns templates with internal components for empty array', async () => {
30
37
  const result = await mergeTemplates([])
31
- expect(result).toBe('<templates></templates>')
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).toBe('<templates></templates>')
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).toBe('<templates><t t-name="Good"><div/></t><t t-name="Also"><span/></t></templates>')
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'])