metaowl 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-04-24
9
+
10
+ ### Added
11
+
12
+ - **Nested layouts support** — layouts can now have parent-child relationships using `setParentLayout()`, `getParentLayout()`, and `getLayoutChain()`. The `createNestedLayoutWrapper()` function creates wrapper components for multi-level nesting. The `defineNestedLayout()` decorator simplifies declaring nested layouts on components.
13
+ - **Image optimization module** — new `modules/image.ts` with `generateSrcSet()`, `calculateAspectRatio()`, `generateSizesAttribute()`, `createResponsiveImage()`, `prefetchImage()`, `prefetchImages()`, `isImageLoaded()`, `getImageDimensions()`, `observeImageVisibility()`, `swapImageSource()`, and `generateDominantColorPlaceholder()`.
14
+ - **Fonts optimization module** — new `modules/fonts.ts` with `defineFontFace()`, `loadFont()`, `loadFontFamily()`, `isFontLoaded()`, `preloadFont()`, `removeFontPreload()`, `createFontFaceRule()`, `injectFontFaceRules()`, `measureTextWidth()`, `estimateFontMetrics()`, `adjustFontForFout()`, and `getFontLoadStatus()`.
15
+
16
+ ### Changed
17
+
18
+ - **TypeScript migration completed** — the framework source, Vite integration, CLI entrypoints,
19
+ and test suite now use TypeScript as the primary source of truth while preserving the existing
20
+ public API and runtime behavior.
21
+ - **Runtime build output separated from source** — publishable JavaScript is now emitted to
22
+ `build/runtime`, and package `main`, `exports`, and `bin` entries resolve from that generated
23
+ runtime output.
24
+
25
+ ### Removed
26
+
27
+ - **Redundant source JavaScript files** — legacy hand-maintained `.js` source files in `modules/`,
28
+ `bin/`, `vite/`, and the root entrypoint were removed in favor of the TypeScript sources and the
29
+ generated runtime build.
30
+
31
+ ### Added
32
+
33
+ - **Release build workflow** — added dedicated runtime build and release helper scripts for
34
+ clean runtime generation, release checks, and package dry-run validation.
35
+
8
36
  ## [0.5.0] - 2026-04-24
9
37
 
10
38
  ### Changed
package/README.md CHANGED
@@ -17,7 +17,7 @@ metaowl is a complete solution for building OWL applications with everything you
17
17
 
18
18
  **Developer Experience:** Composables for common patterns (auth, localStorage, fetching), form handling with validation, error boundaries, and internationalization.
19
19
 
20
- **SEO & PWA:** Sitemap/robots.txt generation, structured data support, service worker integration, web app manifest, and push notifications.
20
+ **SEO & PWA:** Sitemap/robots.txt generation, structured data support, image optimization with srcset generation, font optimization with preloading, service worker integration, web app manifest, and push notifications.
21
21
 
22
22
  **Testing & Quality:** Mock stores, router mocking, component testing utilities, plus bundled ESLint and PostCSS configs.
23
23
 
@@ -44,6 +44,8 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
44
44
  - [Auto-Import](#auto-import)
45
45
  - [Odoo JSON-RPC Service](#odoo-json-rpc-service)
46
46
  - [Composables / Hooks](#composables--hooks)
47
+ - [Image Optimization](#image-optimization)
48
+ - [Font Optimization](#font-optimization)
47
49
  - [CLI Reference](#cli-reference)
48
50
  - [API Reference](#api-reference)
49
51
  - [boot](#bootroutes)
@@ -61,6 +63,8 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
61
63
  - [Forms](#forms-api)
62
64
  - [OdooService](#odooservice-api)
63
65
  - [Composables](#composables-api)
66
+ - [Image API](#image-api)
67
+ - [Fonts API](#fonts-api)
64
68
  - [Vite Plugin](#vite-plugin)
65
69
  - [metaowlPlugin](#metaowlpluginoptions)
66
70
  - [metaowlConfig](#metaowlconfigoptions)
@@ -93,6 +97,8 @@ All powered by a batteries-included Vite plugin that handles the build pipeline,
93
97
  - **Composables** — reusable hooks for auth, localStorage, fetching, and more
94
98
  - **Testing Utilities** — mock store, router mocking, component mount helpers
95
99
  - **SEO Utils** — sitemap, robots.txt, JSON-LD, Open Graph, Twitter Cards
100
+ - **Image Optimization** — responsive srcset generation, lazy loading, placeholder support, dominant color extraction
101
+ - **Font Optimization** — FontFace creation, font preloading, @font-face CSS generation, FOUT handling
96
102
  - **PWA Support** — service worker, manifest generation, push notifications
97
103
  - **SSG generator** — statically pre-renders HTML pages with correct meta tags at build time
98
104
  - **Vite plugin** — handles `COMPONENTS` injection, XML template copying, CSS auto-import, chunk splitting, and env filtering
@@ -389,6 +395,37 @@ If no layout is specified, the `default` layout is used automatically.
389
395
  </templates>
390
396
  ```
391
397
 
398
+ ### Nested Layouts
399
+
400
+ Layouts can be nested in a parent-child hierarchy. Use `setParentLayout()` to define relationships:
401
+
402
+ ```js
403
+ import { setParentLayout, getLayoutChain } from 'metaowl'
404
+
405
+ // Define layout hierarchy
406
+ setParentLayout('inner', 'middle')
407
+ setParentLayout('middle', 'outer')
408
+
409
+ // Get the full chain for a layout
410
+ const chain = getLayoutChain('inner')
411
+ // ['inner', 'middle', 'outer']
412
+ ```
413
+
414
+ Or use the `defineNestedLayout()` decorator:
415
+
416
+ ```js
417
+ import { defineNestedLayout } from 'metaowl'
418
+
419
+ export class AdminDashboard extends Component {
420
+ static template = 'AdminDashboard'
421
+ static layout = 'admin-dashboard'
422
+ }
423
+
424
+ defineNestedLayout('admin-dashboard', 'admin-base')(AdminDashboard)
425
+ ```
426
+
427
+ When a page uses `admin-dashboard` layout, it's rendered with: `outer > middle > admin-dashboard > page`.
428
+
392
429
  ---
393
430
 
394
431
  ## Navigation Guards
@@ -901,6 +938,98 @@ class MyComponent extends Component {
901
938
 
902
939
  ---
903
940
 
941
+ ## Image Optimization
942
+
943
+ Responsive image handling with srcset generation, lazy loading, and placeholder support:
944
+
945
+ ```js
946
+ import { Image } from 'metaowl'
947
+
948
+ // Generate srcset for responsive images
949
+ const srcset = Image.generateSrcSet('https://example.com/image.jpg', [320, 640, 960, 1280])
950
+ // "https://example.com/image.jpg?width=320&quality=80 320w, ..."
951
+
952
+ // Create a fully configured responsive image object
953
+ const responsive = Image.createResponsiveImage({
954
+ src: 'https://example.com/hero.jpg',
955
+ alt: 'Hero image',
956
+ widths: [640, 1024, 1920],
957
+ lazy: true,
958
+ placeholder: true
959
+ })
960
+ // Returns: { src, srcset, loading: 'lazy', decoding: 'async', placeholder, ... }
961
+ ```
962
+
963
+ **Image utilities:**
964
+
965
+ | Function | Description |
966
+ |---|---|
967
+ | `generateSrcSet(src, widths, options)` | Generates responsive srcset string |
968
+ | `calculateAspectRatio(width, height)` | Computes aspect ratio as `W/H` |
969
+ | `generateSizesAttribute(src, breakpoints)` | Generates sizes attribute with breakpoints |
970
+ | `createResponsiveImage(options)` | Creates responsive image object |
971
+ | `prefetchImage(src)` / `prefetchImages(sources)` | Prefetch images for faster loading |
972
+ | `isImageLoaded(img)` | Check if image has finished loading |
973
+ | `getImageDimensions(src)` | Get natural width/height of image |
974
+ | `observeImageVisibility(img, callback)` | IntersectionObserver for lazy loading |
975
+ | `swapImageSource(img, newSrc, newSrcset)` | Swap src/srcset atomically |
976
+ | `generateDominantColorPlaceholder(src)` | Extract dominant color as placeholder |
977
+
978
+ ---
979
+
980
+ ## Font Optimization
981
+
982
+ Font loading and management with preloading and FOUT (Flash of Unstyled Text) handling:
983
+
984
+ ```js
985
+ import { Fonts } from 'metaowl'
986
+
987
+ // Define and load a font
988
+ const fontFace = await Fonts.loadFont({
989
+ family: 'Inter',
990
+ src: 'https://fonts.example.com/inter.woff2',
991
+ weight: 'normal',
992
+ display: 'swap'
993
+ })
994
+
995
+ // Preload font for critical text
996
+ Fonts.preloadFont('Inter', 'https://fonts.example.com/inter.woff2')
997
+
998
+ // Generate @font-face CSS rule
999
+ const cssRule = Fonts.createFontFaceRule({
1000
+ family: 'Inter',
1001
+ src: 'https://fonts.example.com/inter.woff2',
1002
+ weight: 'bold',
1003
+ display: 'optional'
1004
+ })
1005
+ // @font-face { font-family: 'Inter'; src: url("..."); font-weight: bold; font-display: optional; }
1006
+
1007
+ // Inject font faces into document
1008
+ Fonts.injectFontFaceRules({
1009
+ family: 'Inter',
1010
+ src: ['woff2', 'woff'].map(f => `https://fonts.example.com/${f}`),
1011
+ weight: '400 700'
1012
+ })
1013
+ ```
1014
+
1015
+ **Font utilities:**
1016
+
1017
+ | Function | Description |
1018
+ |---|---|
1019
+ | `defineFontFace(options)` | Creates a FontFace object |
1020
+ | `loadFont(options)` | Loads font and tracks it |
1021
+ | `loadFontFamily(family, variants)` | Load multiple variants |
1022
+ | `isFontLoaded(family, weight?)` | Check if font is loaded |
1023
+ | `preloadFont(family, src, options)` | Add preload link for font |
1024
+ | `removeFontPreload(family, weight?)` | Remove preload link |
1025
+ | `createFontFaceRule(options)` | Generate @font-face CSS |
1026
+ | `injectFontFaceRules(options)` | Inject @font-face rules |
1027
+ | `measureTextWidth(text, font, size)` | Measure rendered text width |
1028
+ | `adjustFontForFout(el, fallback, timeout)` | Handle FOUT gracefully |
1029
+ | `getFontLoadStatus()` | Get loaded fonts status |
1030
+
1031
+ ---
1032
+
904
1033
  ## CLI Reference
905
1034
 
906
1035
  metaowl ships four CLI commands that use its own bundled Vite, Prettier, and ESLint binaries — no need to install them separately in your project.
@@ -1119,11 +1248,24 @@ Functions for layout management.
1119
1248
 
1120
1249
  | Function | Description |
1121
1250
  |---|---|
1122
- | `registerLayout(name, Component)` | Register a layout |
1251
+ | `registerLayout(name, Component, options?)` | Register a layout |
1252
+ | `unregisterLayout(name)` | Remove a layout |
1123
1253
  | `getLayout(name)` | Get layout component by name |
1254
+ | `hasLayout(name)` | Check if layout exists |
1255
+ | `getLayoutNames()` | Get all registered layout names |
1124
1256
  | `setDefaultLayout(name)` | Set default layout |
1257
+ | `getDefaultLayout()` | Get default layout name |
1125
1258
  | `resolveLayout(Component, path?)` | Resolve layout for component |
1259
+ | `setRouteLayout(routePath, layoutName)` | Set layout for a route |
1260
+ | `getRouteLayout(routePath)` | Get layout for a route |
1261
+ | `setParentLayout(layoutName, parentName)` | Set parent-child relationship |
1262
+ | `getParentLayout(layoutName)` | Get parent layout name |
1263
+ | `getLayoutChain(layoutName)` | Get full layout chain |
1264
+ | `createNestedLayoutWrapper(layouts, page, props)` | Create nested layout wrapper |
1126
1265
  | `subscribeToLayouts(callback)` | Listen to layout events |
1266
+ | `clearLayouts()` | Clear all layouts |
1267
+ | `defineLayout(name, options?)` | Decorator for layout |
1268
+ | `defineNestedLayout(name, parent, options?)` | Decorator for nested layout |
1127
1269
 
1128
1270
  **Component Layout Property:**
1129
1271
 
@@ -1458,6 +1600,117 @@ const { value, set, get, remove, clear } = useCache('user-prefs', {})
1458
1600
 
1459
1601
  ---
1460
1602
 
1603
+ ### `Image API`
1604
+
1605
+ Image optimization utilities.
1606
+
1607
+ ```ts
1608
+ import { Image } from 'metaowl'
1609
+
1610
+ // Generate srcset for responsive images
1611
+ Image.generateSrcSet('https://example.com/image.jpg', [320, 640, 960])
1612
+
1613
+ // Calculate aspect ratio
1614
+ Image.calculateAspectRatio(1920, 1080) // "16/9"
1615
+
1616
+ // Generate sizes attribute
1617
+ Image.generateSizesAttribute('https://example.com/image.jpg', {
1618
+ '(min-width: 1024px)': 1000,
1619
+ '(min-width: 768px)': 800
1620
+ })
1621
+
1622
+ // Create responsive image object
1623
+ const responsive = Image.createResponsiveImage({
1624
+ src: 'https://example.com/hero.jpg',
1625
+ alt: 'Hero',
1626
+ widths: [320, 640, 1024],
1627
+ lazy: true,
1628
+ placeholder: true
1629
+ })
1630
+
1631
+ // Prefetch images
1632
+ await Image.prefetchImages(['/img1.jpg', '/img2.jpg'])
1633
+
1634
+ // Check if loaded
1635
+ Image.isImageLoaded(imgElement)
1636
+
1637
+ // Get dimensions
1638
+ const dims = await Image.getImageDimensions('https://example.com/image.jpg')
1639
+
1640
+ // Observe visibility
1641
+ const observer = Image.observeImageVisibility(imgElement, (visible) => {
1642
+ if (visible) loadHighResImage()
1643
+ })
1644
+
1645
+ // Swap source
1646
+ Image.swapImageSource(imgElement, '/new-image.jpg', '/new-image-2x.jpg 2x')
1647
+
1648
+ // Generate dominant color placeholder
1649
+ const placeholder = await Image.generateDominantColorPlaceholder(src)
1650
+ ```
1651
+
1652
+ ---
1653
+
1654
+ ### `Fonts API`
1655
+
1656
+ Font optimization utilities.
1657
+
1658
+ ```ts
1659
+ import { Fonts } from 'metaowl'
1660
+
1661
+ // Define a font face
1662
+ const fontFace = Fonts.defineFontFace({
1663
+ family: 'Inter',
1664
+ src: 'https://fonts.example.com/inter.woff2',
1665
+ weight: '400',
1666
+ style: 'normal',
1667
+ display: 'swap'
1668
+ })
1669
+
1670
+ // Load and track a font
1671
+ await Fonts.loadFont({
1672
+ family: 'Inter',
1673
+ src: 'https://fonts.example.com/inter.woff2'
1674
+ })
1675
+
1676
+ // Check if loaded
1677
+ Fonts.isFontLoaded('Inter') // true
1678
+
1679
+ // Preload a font
1680
+ Fonts.preloadFont('Inter', 'https://fonts.example.com/inter.woff2')
1681
+
1682
+ // Remove preload link
1683
+ Fonts.removeFontPreload('Inter')
1684
+
1685
+ // Generate @font-face CSS rule
1686
+ const rule = Fonts.createFontFaceRule({
1687
+ family: 'Inter',
1688
+ src: 'https://fonts.example.com/inter.woff2',
1689
+ weight: 'bold'
1690
+ })
1691
+
1692
+ // Inject @font-face rules into document
1693
+ Fonts.injectFontFaceRules({
1694
+ family: 'Inter',
1695
+ src: ['woff2', 'woff'].map(f => `https://fonts.example.com/${f}`),
1696
+ weight: '400 700'
1697
+ })
1698
+
1699
+ // Measure text width
1700
+ Fonts.measureTextWidth('Hello', 'Inter', 16)
1701
+
1702
+ // Adjust for FOUT
1703
+ await Fonts.adjustFontForFout(element, 'sans-serif', 3000)
1704
+
1705
+ // Get loaded font status
1706
+ Fonts.getFontLoadStatus() // { 'Inter': true }
1707
+
1708
+ // Clear all loaded fonts
1709
+ Fonts.clearLoadedFonts()
1710
+ ```
1711
+
1712
+ ---
1713
+
1461
1714
  ## Vite Plugin
1462
1715
 
1463
1716
  ### `metaowlPlugin(options)`
@@ -1,5 +1,5 @@
1
1
  import { mountApp, configureOwl } from './modules/app-mounter.js';
2
- import { buildLayouts, clearLayouts, createLayoutWrapper, defineLayout, discoverLayouts, getCurrentLayout, getDefaultLayout, getLayout, getLayoutNames, getRouteLayout, hasLayout, layout, mountWithLayout, registerLayout, resolveLayout, setDefaultLayout, setRouteLayout, subscribeToLayouts, unregisterLayout } from './modules/layouts.js';
2
+ import { buildLayouts, clearLayouts, createLayoutWrapper, createNestedLayoutWrapper, defineLayout, defineNestedLayout, discoverLayouts, getCurrentLayout, getDefaultLayout, getLayout, getLayoutChain, getLayoutNames, getParentLayout, getRouteLayout, hasLayout, layout, mountWithLayout, registerLayout, resolveLayout, setDefaultLayout, setParentLayout, setRouteLayout, subscribeToLayouts, unregisterLayout } from './modules/layouts.js';
3
3
  import { afterEach, _setSpaNavigationCallback, back, beforeEach, cancelNavigation, forward, getCurrentRoute, getPreviousRoute, go, isNavigating, isSpaMode, navigate, navigateTo, processRoutes, push, replace, router, setSpaMode } from './modules/router.js';
4
4
  import { Link, registerLinkTemplate } from './modules/link.js';
5
5
  import { buildRoutes, createCatchAllRoute, createRedirectRoute, defineRoute, findRoute, generateUrl, isDynamicRoute, matchRoute, route, validateRouteParams } from './modules/file-router.js';
@@ -13,6 +13,8 @@ import { createCanonicalUrl, generateOpenGraph, generateRobotsTxt, generateSitem
13
13
  import { cache, checkCapabilities, generateManifest, getStorageInfo, isOnline, isStandalone, PWA, registerServiceWorker, requestPersistentStorage, showNotification, subscribeToConnectivity, subscribeToPush, sync, unregisterServiceWorker, unsubscribeFromPush } from './modules/pwa.js';
14
14
  import Cache from './modules/cache.js';
15
15
  import Fetch from './modules/fetch.js';
16
+ import * as Fonts from './modules/fonts.js';
17
+ import { ImageOptimizer } from './modules/image.js';
16
18
  import * as Meta from './modules/meta.js';
17
19
  import { Store, createPersistencePlugin, createStore } from './modules/store.js';
18
20
  let appRoutes = null;
@@ -127,7 +129,7 @@ export async function boot(routesOrModules = {}, layoutsOrModules = null, option
127
129
  await mountApp(resolvedRoute);
128
130
  }
129
131
  export { Fetch, Cache, configureOwl, Meta, buildRoutes, Store, createPersistencePlugin, createStore };
130
- export { registerLayout, unregisterLayout, getLayout, hasLayout, getLayoutNames, setDefaultLayout, getDefaultLayout, resolveLayout, setRouteLayout, getRouteLayout, createLayoutWrapper, mountWithLayout, getCurrentLayout, subscribeToLayouts, clearLayouts, layout, defineLayout, buildLayouts, discoverLayouts };
132
+ export { registerLayout, unregisterLayout, getLayout, hasLayout, getLayoutNames, setDefaultLayout, getDefaultLayout, resolveLayout, setRouteLayout, getRouteLayout, createLayoutWrapper, createNestedLayoutWrapper, mountWithLayout, getCurrentLayout, subscribeToLayouts, clearLayouts, layout, defineLayout, defineNestedLayout, buildLayouts, discoverLayouts, setParentLayout, getParentLayout, getLayoutChain };
131
133
  export { processRoutes, beforeEach, afterEach, getCurrentRoute, getPreviousRoute, isNavigating, cancelNavigation, navigate, navigateTo, push, replace, back, forward, go, router, setSpaMode, isSpaMode };
132
134
  export { Link, registerLinkTemplate };
133
135
  export { matchRoute, isDynamicRoute, findRoute, generateUrl, validateRouteParams, createCatchAllRoute, createRedirectRoute, defineRoute, route };
@@ -139,3 +141,4 @@ export { useAuth, useLocalStorage, useFetch, useDebounce, useThrottle, useWindow
139
141
  export { createMockStore, mockRouter, mountComponent, wait, nextTick, flushPromises, userEvent, dom, TestUtils };
140
142
  export { generateSitemap, generateRobotsTxt, jsonLd, createCanonicalUrl, generateOpenGraph, generateTwitterCard, validateSitemap, getPriorityByDepth, generateSitemapIndex, SEO };
141
143
  export { generateManifest, registerServiceWorker, unregisterServiceWorker, isStandalone, isOnline, subscribeToConnectivity, requestPersistentStorage, getStorageInfo, sync, subscribeToPush, unsubscribeFromPush, showNotification, cache, checkCapabilities, PWA };
144
+ export { ImageOptimizer as Image, Fonts };
@@ -16,7 +16,15 @@ let config = { ...defaults };
16
16
  let currentApp = null;
17
17
  let cachedTemplates = null;
18
18
  export function configureOwl(nextConfig) {
19
- config = { ...defaults, ...nextConfig };
19
+ config = {
20
+ ...defaults,
21
+ ...nextConfig,
22
+ translatableAttributes: nextConfig.translatableAttributes
23
+ ? Array.isArray(nextConfig.translatableAttributes)
24
+ ? nextConfig.translatableAttributes
25
+ : [nextConfig.translatableAttributes]
26
+ : defaults.translatableAttributes
27
+ };
20
28
  }
21
29
  export async function mountApp(route) {
22
30
  const components = typeof COMPONENTS !== 'undefined' ? COMPONENTS : [];
@@ -0,0 +1,38 @@
1
+ export const MAGIC_STRINGS = {
2
+ STORE_SESSION_KEY: 'metaowl:odoo:session',
3
+ STORE_CSRF_KEY: 'metaowl:odoo:csrf',
4
+ MOUNT_ELEMENT_ID: 'metaowl',
5
+ LINK_TEMPLATE_NAME: 'Link'
6
+ };
7
+ export const ROUTE_PATTERN_CONFIG = {
8
+ optionalParam: /\/:([^/(]+)\?/g,
9
+ catchAll: /\/:([^/(]+)\(\.\*\)/g,
10
+ namedParam: /:([^/(?\s]+)/g,
11
+ wildcard: /\*/g,
12
+ separator: /\//g
13
+ };
14
+ export function buildRouteRegexPattern(path) {
15
+ const pattern = path
16
+ .replace(ROUTE_PATTERN_CONFIG.separator, '\\/')
17
+ .replace(ROUTE_PATTERN_CONFIG.catchAll, '/(.*)')
18
+ .replace(ROUTE_PATTERN_CONFIG.optionalParam, '(?:/([^/]+))?')
19
+ .replace(ROUTE_PATTERN_CONFIG.namedParam, '([^/]+)')
20
+ .replace(ROUTE_PATTERN_CONFIG.wildcard, '(.*)');
21
+ return '^' + pattern + '$';
22
+ }
23
+ export function buildSimpleRoutePattern(routePath) {
24
+ const pattern = routePath
25
+ .replace(ROUTE_PATTERN_CONFIG.separator, '\\/')
26
+ .replace(ROUTE_PATTERN_CONFIG.catchAll, '(.*)')
27
+ .replace(ROUTE_PATTERN_CONFIG.optionalParam, '(?:/([^/]+))?')
28
+ .replace(ROUTE_PATTERN_CONFIG.namedParam, '([^/]+)')
29
+ .replace(ROUTE_PATTERN_CONFIG.wildcard, '(.*)');
30
+ return '^' + pattern + '$';
31
+ }
32
+ export function normalizeRoutePath(path) {
33
+ return (path.replace(/\/$/, '') || '/');
34
+ }
35
+ export function normalizePathForComparison(path) {
36
+ return normalizeRoutePath(path);
37
+ }
38
+ export const EXTERNAL_URL_REGEX = /^(https?:|\/\/|mailto:|tel:|ftp:|file:|javascript:)/i;
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * File-based routing with dynamic route parameter support.
5
5
  */
6
+ import { buildRouteRegexPattern } from './constants.js';
6
7
  export function pathFromKey(key) {
7
8
  const relativePath = key.replace(/^\.\/pages\//, '');
8
9
  const parts = relativePath.split('/');
@@ -43,11 +44,7 @@ function extractParamNames(filePath) {
43
44
  return params;
44
45
  }
45
46
  function buildRegexPattern(path) {
46
- let pattern = path.replace(/\//g, '\\/');
47
- pattern = pattern.replace(/:([^/(]+)\(\.\*\)/g, '([^/]+(?:/[^/]+)*)');
48
- pattern = pattern.replace(/\/:([^/(]+)\?/g, '(?:/([^/]+))?');
49
- pattern = pattern.replace(/:([^/(\s]+)/g, '([^/]+)');
50
- return '^' + pattern + '$';
47
+ return buildRouteRegexPattern(path);
51
48
  }
52
49
  export function matchRoute(pattern, path) {
53
50
  const paramNames = [];
@@ -74,24 +71,29 @@ export function isDynamicRoute(path) {
74
71
  return path.includes(':');
75
72
  }
76
73
  function componentFromModule(mod, key) {
77
- if (typeof mod.default === 'function')
74
+ if (typeof mod.default === 'function') {
78
75
  return mod.default;
79
- const named = Object.values(mod).find((value) => typeof value === 'function');
80
- if (!named) {
76
+ }
77
+ const funcs = Object.values(mod).filter((v) => typeof v === 'function');
78
+ if (funcs.length === 0) {
81
79
  throw new Error(`[metaowl] No component export found in "${key}"`);
82
80
  }
83
- return named;
81
+ return funcs[0];
84
82
  }
85
83
  export function buildRoutes(modules) {
86
84
  const routes = [];
85
+ const nameCounts = {};
87
86
  for (const [key, mod] of Object.entries(modules)) {
88
87
  const derivedPath = pathFromKey(key);
89
88
  const component = componentFromModule(mod, key);
90
89
  const routeConfig = component.route || {};
91
90
  const routePath = typeof routeConfig.path === 'string' ? routeConfig.path : derivedPath;
92
- const routeName = routePath === '/'
91
+ const baseName = routePath === '/'
93
92
  ? 'index'
94
93
  : routePath.slice(1).replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
94
+ const routeName = nameCounts[baseName] !== undefined
95
+ ? `${baseName}-${++nameCounts[baseName]}`
96
+ : (nameCounts[baseName] = 0, baseName);
95
97
  const route = {
96
98
  name: routeName,
97
99
  path: [routePath],
@@ -0,0 +1,172 @@
1
+ /**
2
+ * @module Fonts
3
+ *
4
+ * Font optimization utilities for metaowl applications.
5
+ * Provides font loading strategies, font face declarations, and font display optimization.
6
+ */
7
+ const loadedFonts = new Map();
8
+ const fontPreloadLinks = new Map();
9
+ export function defineFontFace(options) {
10
+ const { family, src, weight = 'normal', style = 'normal', display = 'swap', unicodeRange } = options;
11
+ const srcString = Array.isArray(src) ? src.map((s) => `url("${s}")`).join(', ') : `url("${src}")`;
12
+ const weightStr = Array.isArray(weight) ? weight.join(' ') : weight;
13
+ const descriptors = {
14
+ weight: weightStr,
15
+ style,
16
+ display,
17
+ unicodeRange: unicodeRange || 'U+0-FFFF'
18
+ };
19
+ const fontFace = new FontFace(`${family}-${weightStr}`, srcString, descriptors);
20
+ return fontFace;
21
+ }
22
+ export async function loadFont(options) {
23
+ const { family, weight = 'normal' } = options;
24
+ const key = `${family}-${weight}`;
25
+ if (!loadedFonts.has(family)) {
26
+ loadedFonts.set(family, new Set());
27
+ }
28
+ const fontFace = defineFontFace(options);
29
+ try {
30
+ await fontFace.load();
31
+ document.fonts.add(fontFace);
32
+ const familySet = loadedFonts.get(family);
33
+ familySet.add(key);
34
+ return fontFace;
35
+ }
36
+ catch (error) {
37
+ console.warn(`[metaowl] Failed to load font ${family}:`, error);
38
+ throw error;
39
+ }
40
+ }
41
+ export async function loadFontFamily(family, variants) {
42
+ return Promise.all(variants.map((variant) => loadFont({ ...variant, family })));
43
+ }
44
+ export function isFontLoaded(family, weight) {
45
+ if (!loadedFonts.has(family)) {
46
+ return false;
47
+ }
48
+ if (weight) {
49
+ return loadedFonts.get(family).has(`${family}-${weight}`);
50
+ }
51
+ return loadedFonts.get(family).size > 0;
52
+ }
53
+ export function preloadFont(family, src, options = {}) {
54
+ const { weight, as = 'font', type = 'font/woff2' } = options;
55
+ const linkId = `metaowl-font-preload-${family}-${weight || 'normal'}`;
56
+ if (document.getElementById(linkId)) {
57
+ return;
58
+ }
59
+ const link = document.createElement('link');
60
+ link.id = linkId;
61
+ link.rel = 'preload';
62
+ link.as = as;
63
+ link.href = src;
64
+ link.crossOrigin = 'anonymous';
65
+ if (type) {
66
+ link.type = type;
67
+ }
68
+ document.head.appendChild(link);
69
+ fontPreloadLinks.set(linkId, link);
70
+ }
71
+ export function removeFontPreload(family, weight) {
72
+ const linkId = `metaowl-font-preload-${family}-${weight || 'normal'}`;
73
+ const link = fontPreloadLinks.get(linkId);
74
+ if (link) {
75
+ link.remove();
76
+ fontPreloadLinks.delete(linkId);
77
+ }
78
+ }
79
+ export function generateFontDisplayStyle(display) {
80
+ return `font-display: ${display};`;
81
+ }
82
+ export function createFontFaceRule(options) {
83
+ const { family, src, weight = 'normal', style = 'normal', display = 'swap', unicodeRange } = options;
84
+ const srcString = Array.isArray(src) ? src.map((s) => `url("${s}")`).join(', ') : `url("${src}")`;
85
+ const weightStr = Array.isArray(weight) ? weight.join(' ') : weight;
86
+ let rule = '@font-face {\n';
87
+ rule += ` font-family: '${family}';\n`;
88
+ rule += ` src: ${srcString};\n`;
89
+ rule += ` font-weight: ${weightStr};\n`;
90
+ rule += ` font-style: ${style};\n`;
91
+ rule += ` font-display: ${display};\n`;
92
+ if (unicodeRange) {
93
+ rule += ` unicode-range: ${unicodeRange};\n`;
94
+ }
95
+ rule += '}';
96
+ return rule;
97
+ }
98
+ export function injectFontFaceRules(options) {
99
+ const rules = Array.isArray(options) ? options : [options];
100
+ const styleId = 'metaowl-font-faces';
101
+ let styleEl = document.getElementById(styleId);
102
+ if (!styleEl) {
103
+ styleEl = document.createElement('style');
104
+ styleEl.id = styleId;
105
+ document.head.appendChild(styleEl);
106
+ }
107
+ const cssText = rules.map(createFontFaceRule).join('\n\n');
108
+ styleEl.textContent += cssText;
109
+ }
110
+ export function measureTextWidth(text, font, size = 16) {
111
+ const canvas = document.createElement('canvas');
112
+ const ctx = canvas.getContext('2d');
113
+ if (!ctx) {
114
+ const numericSize = typeof size === 'string' ? parseInt(size, 10) : size;
115
+ return text.length * numericSize * 0.5;
116
+ }
117
+ const fontString = typeof size === 'number' ? `${size}px ${font}` : `${size} ${font}`;
118
+ ctx.font = fontString;
119
+ return ctx.measureText(text).width;
120
+ }
121
+ export function estimateFontMetrics(el) {
122
+ const style = window.getComputedStyle(el);
123
+ const family = style.fontFamily;
124
+ const fontMetricsMap = {
125
+ 'Arial': { family: 'Arial', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
126
+ 'Helvetica': { family: 'Helvetica', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
127
+ 'Times New Roman': { family: 'Times New Roman', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
128
+ 'Georgia': { family: 'Georgia', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 },
129
+ 'system-ui': { family: 'system-ui', ascent: 0.8, descent: 0.2, lineGap: 0, unitsPerEm: 2048 }
130
+ };
131
+ return fontMetricsMap[family] || null;
132
+ }
133
+ export function adjustFontForFout(el, _fallbackFont = 'sans-serif', timeout = 3000) {
134
+ return new Promise((resolve) => {
135
+ el.style.setProperty('font-display', 'block');
136
+ const timer = setTimeout(() => {
137
+ el.classList.add('metaowl-font-fout');
138
+ resolve();
139
+ }, timeout);
140
+ document.fonts.ready.then(() => {
141
+ clearTimeout(timer);
142
+ el.classList.add('metaowl-font-loaded');
143
+ resolve();
144
+ });
145
+ });
146
+ }
147
+ export function getFontLoadStatus() {
148
+ const status = {};
149
+ for (const [family, weights] of loadedFonts.entries()) {
150
+ status[family] = weights.size > 0;
151
+ }
152
+ return status;
153
+ }
154
+ export function clearLoadedFonts() {
155
+ loadedFonts.clear();
156
+ }
157
+ export const Fonts = {
158
+ defineFontFace,
159
+ loadFont,
160
+ loadFontFamily,
161
+ isFontLoaded,
162
+ preloadFont,
163
+ removeFontPreload,
164
+ generateFontDisplayStyle,
165
+ createFontFaceRule,
166
+ injectFontFaceRules,
167
+ measureTextWidth,
168
+ estimateFontMetrics,
169
+ adjustFontForFout,
170
+ getFontLoadStatus,
171
+ clearLoadedFonts
172
+ };
@@ -57,6 +57,17 @@ export async function loadLocaleMessages(locale, messages) {
57
57
  state.loading = false;
58
58
  }
59
59
  }
60
+ export async function load(options) {
61
+ const { locale, messages, fallbackLocale } = options;
62
+ if (fallbackLocale) {
63
+ state.fallbackLocale = fallbackLocale;
64
+ }
65
+ state.locale = locale;
66
+ document.documentElement.lang = locale;
67
+ if (messages) {
68
+ await loadLocaleMessages(locale, messages);
69
+ }
70
+ }
60
71
  export function t(key, values = {}, defaultMessage) {
61
72
  const locale = state.locale;
62
73
  const fallbackLocale = state.fallbackLocale;
@@ -138,6 +149,8 @@ export const i18n = {
138
149
  get messages() { return state.messages; },
139
150
  configure: configureI18n,
140
151
  setLocale,
152
+ load,
153
+ loadLocaleMessages,
141
154
  t,
142
155
  formatDate,
143
156
  formatNumber,
@@ -0,0 +1,175 @@
1
+ /**
2
+ * @module Image
3
+ *
4
+ * Image optimization utilities for metaowl applications.
5
+ * Provides lazy loading, responsive srcset generation, and placeholder support.
6
+ */
7
+ const DEFAULT_WIDTHS = [320, 640, 960, 1280, 1600, 1920];
8
+ const DEFAULT_QUALITY = 80;
9
+ export function generateSrcSet(baseSrc, widths = DEFAULT_WIDTHS, options = {}) {
10
+ const { format = 'original', quality = DEFAULT_QUALITY } = options;
11
+ const srcsetParts = [];
12
+ for (const width of widths) {
13
+ const url = buildOptimizedUrl(baseSrc, width, format, quality);
14
+ srcsetParts.push(`${url} ${width}w`);
15
+ }
16
+ return srcsetParts.join(', ');
17
+ }
18
+ function buildOptimizedUrl(baseSrc, width, format, quality) {
19
+ try {
20
+ const url = new URL(baseSrc);
21
+ if (format !== 'original') {
22
+ url.searchParams.set('format', format);
23
+ }
24
+ url.searchParams.set('width', String(width));
25
+ url.searchParams.set('quality', String(quality));
26
+ return url.toString();
27
+ }
28
+ catch {
29
+ const separator = baseSrc.includes('?') ? '&' : '?';
30
+ return `${baseSrc}${separator}width=${width}&quality=${quality}`;
31
+ }
32
+ }
33
+ export function calculateAspectRatio(width, height) {
34
+ const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
35
+ const divisor = gcd(width, height);
36
+ return `${width / divisor}/${height / divisor}`;
37
+ }
38
+ export function generateSizesAttribute(src, breakpoints = {}) {
39
+ const defaultBreakpoints = {
40
+ '(min-width: 1280px)': 1200,
41
+ '(min-width: 1024px)': 1000,
42
+ '(min-width: 768px)': 720,
43
+ '(min-width: 480px)': 480
44
+ };
45
+ const merged = { ...defaultBreakpoints, ...breakpoints };
46
+ const parts = [];
47
+ for (const [condition, size] of Object.entries(merged)) {
48
+ parts.push(`${condition} ${size}px`);
49
+ }
50
+ parts.push(`${Math.min(...Object.values(merged))}px`);
51
+ return parts.join(', ');
52
+ }
53
+ export function createResponsiveImage(options) {
54
+ const { src, alt = '', widths = DEFAULT_WIDTHS, format = 'original', quality = DEFAULT_QUALITY, lazy = true, placeholder = false, placeholderType = 'blur' } = options;
55
+ const srcset = generateSrcSet(src, widths, { format, quality });
56
+ const filteredWidths = widths.filter((w) => w <= 1920);
57
+ const defaultWidth = filteredWidths.length > 0 ? filteredWidths[0] : widths[0];
58
+ const result = {
59
+ src: buildOptimizedUrl(src, defaultWidth, format, quality),
60
+ srcset,
61
+ width: defaultWidth,
62
+ height: 0,
63
+ alt,
64
+ loading: lazy ? 'lazy' : 'eager',
65
+ decoding: 'async'
66
+ };
67
+ if (placeholder) {
68
+ result.placeholder = placeholderType === 'blur' ? buildPlaceholderBlur(src) : undefined;
69
+ result.blurDataURL = result.placeholder;
70
+ }
71
+ return result;
72
+ }
73
+ function buildPlaceholderBlur(src) {
74
+ try {
75
+ const url = new URL(src);
76
+ url.searchParams.set('w', '10');
77
+ url.searchParams.set('q', '10');
78
+ url.searchParams.set('blur', '10');
79
+ return url.toString();
80
+ }
81
+ catch {
82
+ return src;
83
+ }
84
+ }
85
+ export function prefetchImage(src) {
86
+ return new Promise((resolve, reject) => {
87
+ const img = new Image();
88
+ img.onload = () => resolve();
89
+ img.onerror = reject;
90
+ img.src = src;
91
+ });
92
+ }
93
+ export async function prefetchImages(sources) {
94
+ await Promise.all(sources.map(prefetchImage));
95
+ }
96
+ export function isImageLoaded(img) {
97
+ return img.complete && img.naturalHeight > 0;
98
+ }
99
+ export function getImageDimensions(src) {
100
+ return new Promise((resolve, reject) => {
101
+ const img = new Image();
102
+ img.onload = () => {
103
+ resolve({ width: img.naturalWidth, height: img.naturalHeight });
104
+ };
105
+ img.onerror = reject;
106
+ img.src = src;
107
+ });
108
+ }
109
+ export function observeImageVisibility(img, callback, options = {}) {
110
+ const defaultOptions = {
111
+ root: null,
112
+ rootMargin: '50px',
113
+ threshold: 0.01,
114
+ ...options
115
+ };
116
+ const observer = new IntersectionObserver((entries) => {
117
+ for (const entry of entries) {
118
+ callback(entry.isIntersecting);
119
+ }
120
+ }, defaultOptions);
121
+ observer.observe(img);
122
+ return observer;
123
+ }
124
+ export function swapImageSource(img, newSrc, newSrcset) {
125
+ if (newSrcset) {
126
+ img.srcset = newSrcset;
127
+ }
128
+ if (newSrc) {
129
+ img.src = newSrc;
130
+ }
131
+ }
132
+ export function generateDominantColorPlaceholder(src) {
133
+ return new Promise((resolve) => {
134
+ const img = new Image();
135
+ img.crossOrigin = 'anonymous';
136
+ img.onload = () => {
137
+ const canvas = document.createElement('canvas');
138
+ const ctx = canvas.getContext('2d');
139
+ if (!ctx) {
140
+ resolve({ type: 'solid', value: '#cccccc' });
141
+ return;
142
+ }
143
+ canvas.width = 1;
144
+ canvas.height = 1;
145
+ ctx.drawImage(img, 0, 0, 1, 1);
146
+ try {
147
+ const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
148
+ resolve({
149
+ type: 'dominant',
150
+ value: `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
151
+ });
152
+ }
153
+ catch {
154
+ resolve({ type: 'solid', value: '#cccccc' });
155
+ }
156
+ };
157
+ img.onerror = () => {
158
+ resolve({ type: 'solid', value: '#cccccc' });
159
+ };
160
+ img.src = src;
161
+ });
162
+ }
163
+ export const ImageOptimizer = {
164
+ generateSrcSet,
165
+ calculateAspectRatio,
166
+ generateSizesAttribute,
167
+ createResponsiveImage,
168
+ prefetchImage,
169
+ prefetchImages,
170
+ isImageLoaded,
171
+ getImageDimensions,
172
+ observeImageVisibility,
173
+ swapImageSource,
174
+ generateDominantColorPlaceholder
175
+ };
@@ -63,6 +63,32 @@ export function setRouteLayout(routePath, layoutName) {
63
63
  export function getRouteLayout(routePath) {
64
64
  return routeLayouts.get(routePath);
65
65
  }
66
+ export function setParentLayout(layoutName, parentLayoutName) {
67
+ const layout = getLayout(layoutName);
68
+ if (!layout) {
69
+ console.warn(`[metaowl] Cannot set parent for unregistered layout "${layoutName}"`);
70
+ return;
71
+ }
72
+ ;
73
+ layout.parentLayout = parentLayoutName;
74
+ }
75
+ export function getParentLayout(layoutName) {
76
+ const layout = getLayout(layoutName);
77
+ return layout?.parentLayout;
78
+ }
79
+ export function getLayoutChain(layoutName) {
80
+ const chain = [];
81
+ let current = layoutName;
82
+ while (current) {
83
+ if (chain.includes(current)) {
84
+ console.warn(`[metaowl] Circular layout hierarchy detected for "${current}"`);
85
+ break;
86
+ }
87
+ chain.push(current);
88
+ current = getParentLayout(current);
89
+ }
90
+ return chain;
91
+ }
66
92
  export function createLayoutWrapper(layoutComponent, pageComponent, props = {}) {
67
93
  const LayoutClass = layoutComponent;
68
94
  const PageClass = pageComponent;
@@ -84,15 +110,32 @@ export function createLayoutWrapper(layoutComponent, pageComponent, props = {})
84
110
  }
85
111
  };
86
112
  }
113
+ export function createNestedLayoutWrapper(layoutChain, pageComponent, props = {}) {
114
+ if (layoutChain.length === 0) {
115
+ return pageComponent;
116
+ }
117
+ const [outerLayout, ...innerChain] = layoutChain;
118
+ if (innerChain.length === 0) {
119
+ return createLayoutWrapper(outerLayout, pageComponent, props);
120
+ }
121
+ return createLayoutWrapper(outerLayout, createNestedLayoutWrapper(innerChain, pageComponent, props), props);
122
+ }
87
123
  export async function mountWithLayout(pageComponent, target, options = {}, config = {}) {
88
124
  const { routePath, props = {}, templates } = options;
89
125
  const layoutName = resolveLayout(pageComponent, routePath);
90
- const LayoutClass = getLayout(layoutName);
91
- if (!LayoutClass) {
126
+ const layoutChain = getLayoutChain(layoutName);
127
+ if (layoutChain.length === 0) {
128
+ console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`);
129
+ return await mount(pageComponent, target, { ...config, props, templates });
130
+ }
131
+ const layoutClasses = layoutChain
132
+ .map((name) => getLayout(name))
133
+ .filter((l) => !!l);
134
+ if (layoutClasses.length === 0) {
92
135
  console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`);
93
136
  return await mount(pageComponent, target, { ...config, props, templates });
94
137
  }
95
- const WrapperClass = createLayoutWrapper(LayoutClass, pageComponent, props);
138
+ const WrapperClass = createNestedLayoutWrapper(layoutClasses, pageComponent, props);
96
139
  const instance = await mount(WrapperClass, target, { ...config, templates });
97
140
  currentLayout = instance;
98
141
  for (const listener of listeners) {
@@ -131,6 +174,14 @@ export function defineLayout(name, options = {}) {
131
174
  return componentClass;
132
175
  };
133
176
  }
177
+ export function defineNestedLayout(name, parentLayout, options = {}) {
178
+ return function decorator(componentClass) {
179
+ componentClass.layout = name;
180
+ componentClass.parentLayout = parentLayout;
181
+ componentClass.layoutOptions = options;
182
+ return componentClass;
183
+ };
184
+ }
134
185
  export function buildLayouts(modules) {
135
186
  const discoveredLayouts = {};
136
187
  for (const [key, mod] of Object.entries(modules)) {
@@ -4,7 +4,7 @@
4
4
  * SPA Link component for metaowl with automatic external link detection.
5
5
  */
6
6
  import { Component, onMounted, onWillUnmount, useState } from '@odoo/owl';
7
- const EXTERNAL_URL_REGEX = /^(https?:|\/\/|mailto:|tel:|ftp:|file:|javascript:)/i;
7
+ import { EXTERNAL_URL_REGEX } from './constants.js';
8
8
  function isExternalUrl(url) {
9
9
  if (!url || typeof url !== 'string')
10
10
  return false;
@@ -42,6 +42,11 @@ export class Link extends Component {
42
42
  this.state = useState({
43
43
  isActive: false
44
44
  });
45
+ this._updateActiveState = () => {
46
+ if (this.props.activeClass) {
47
+ this.state.isActive = isActiveLink(this.props.to, document.location.pathname);
48
+ }
49
+ };
45
50
  onMounted(() => {
46
51
  this._updateActiveState();
47
52
  window.addEventListener('popstate', this._updateActiveState);
@@ -49,11 +54,6 @@ export class Link extends Component {
49
54
  onWillUnmount(() => {
50
55
  window.removeEventListener('popstate', this._updateActiveState);
51
56
  });
52
- this._updateActiveState = () => {
53
- if (this.props.activeClass) {
54
- this.state.isActive = isActiveLink(this.props.to, document.location.pathname);
55
- }
56
- };
57
57
  }
58
58
  get linkClasses() {
59
59
  const classes = [];
@@ -3,12 +3,13 @@
3
3
  *
4
4
  * Odoo JSON-RPC Service for MetaOwl applications.
5
5
  */
6
+ import { MAGIC_STRINGS } from './constants.js';
6
7
  let config = null;
7
8
  let session = null;
8
9
  let csrfToken = null;
9
10
  const authListeners = [];
10
- const SESSION_KEY = 'metaowl:odoo:session';
11
- const CSRF_KEY = 'metaowl:odoo:csrf';
11
+ const SESSION_KEY = MAGIC_STRINGS.STORE_SESSION_KEY;
12
+ const CSRF_KEY = MAGIC_STRINGS.STORE_CSRF_KEY;
12
13
  export function configure(nextConfig) {
13
14
  config = {
14
15
  persistSession: true,
@@ -99,7 +100,7 @@ async function jsonRpc(service, method, args = []) {
99
100
  }
100
101
  const setCookie = response.headers.get('set-cookie');
101
102
  if (setCookie?.includes('csrf_token')) {
102
- const match = setCookie.match(/csrf_token=([^;]+)/);
103
+ const match = setCookie.match(/csrf_token=([a-zA-Z0-9_-]+)/);
103
104
  if (match) {
104
105
  csrfToken = match[1] ?? null;
105
106
  saveSession();
@@ -232,12 +232,22 @@ export const cache = {
232
232
  return info;
233
233
  }
234
234
  };
235
- export function checkCapabilities() {
235
+ export async function checkCapabilities() {
236
+ let backgroundSync = false;
237
+ if ('serviceWorker' in navigator) {
238
+ try {
239
+ const registration = await navigator.serviceWorker.ready;
240
+ backgroundSync = 'sync' in registration;
241
+ }
242
+ catch {
243
+ // Service worker not available
244
+ }
245
+ }
236
246
  return {
237
247
  serviceWorker: 'serviceWorker' in navigator,
238
248
  push: 'PushManager' in window,
239
249
  notifications: 'Notification' in window,
240
- backgroundSync: false,
250
+ backgroundSync,
241
251
  persistentStorage: Boolean(navigator.storage?.persist),
242
252
  addToHomeScreen: !isStandalone(),
243
253
  offline: 'serviceWorker' in navigator
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Enhanced router with navigation guards support.
5
5
  */
6
+ import { buildSimpleRoutePattern } from './constants.js';
6
7
  let currentRoute = null;
7
8
  let previousRoute = null;
8
9
  const beforeEachGuards = [];
@@ -45,17 +46,11 @@ class Router {
45
46
  }
46
47
  pathMatches(routePath, currentPath) {
47
48
  if (!routePath.includes(':') && !routePath.includes('*')) {
48
- const normalizedRoute = routePath.replace(/\/$/, '') || '/';
49
- const normalizedCurrent = currentPath.replace(/\/$/, '') || '/';
49
+ const normalizedRoute = (routePath.replace(/\/$/, '') || '/');
50
+ const normalizedCurrent = (currentPath.replace(/\/$/, '') || '/');
50
51
  return normalizedRoute === normalizedCurrent;
51
52
  }
52
- let pattern = routePath
53
- .replace(/\//g, '\\/')
54
- .replace(/:([^/(]+)\(\.\*\)/g, '(.*)')
55
- .replace(/\/:([^/(]+)\?/g, '(?:/([^/]+))?')
56
- .replace(/:([^/(?\s]+)/g, '([^/]+)')
57
- .replace(/\*/g, '(.*)');
58
- pattern = '^' + pattern + '$';
53
+ const pattern = buildSimpleRoutePattern(routePath);
59
54
  return new RegExp(pattern).test(currentPath);
60
55
  }
61
56
  extractParams(route, path) {
@@ -166,10 +166,10 @@ export function generateSitemapIndex(sitemaps) {
166
166
  }
167
167
  function escapeXml(str) {
168
168
  return str
169
- .replace(/&/g, '&')
170
- .replace(/</g, '<')
171
- .replace(/>/g, '>')
172
- .replace(/"/g, '"')
169
+ .replace(/&/g, '&amp;')
170
+ .replace(/</g, '&lt;')
171
+ .replace(/>/g, '&gt;')
172
+ .replace(/"/g, '&quot;')
173
173
  .replace(/'/g, '&#x27;');
174
174
  }
175
175
  export const SEO = {
@@ -105,9 +105,11 @@ export class Store {
105
105
  reset() {
106
106
  if (this._config.state) {
107
107
  const initialState = this._config.state();
108
- Object.keys(this._state).forEach((key) => {
109
- delete this._state[key];
110
- });
108
+ const keys = Object.keys(this._state);
109
+ for (const key of keys) {
110
+ ;
111
+ this._state[key] = undefined;
112
+ }
111
113
  Object.assign(this._state, initialState);
112
114
  }
113
115
  }
@@ -7,6 +7,18 @@ import { config as dotenvConfig } from 'dotenv';
7
7
  import { globSync } from 'glob';
8
8
  import tsconfigPaths from 'vite-tsconfig-paths';
9
9
  const require = createRequire(import.meta.url);
10
+ function isPathSafe(path, root) {
11
+ const resolved = resolve(root, path);
12
+ return resolved.startsWith(resolve(root));
13
+ }
14
+ function sanitizeDirOption(path, root, fallback) {
15
+ if (!path)
16
+ return fallback;
17
+ if (isPathSafe(path, root))
18
+ return path;
19
+ console.warn(`[metaowl] Path "${path}" escapes root, using default "${fallback}"`);
20
+ return fallback;
21
+ }
10
22
  function resolveOwlPath() {
11
23
  return require.resolve('@odoo/owl/dist/owl.es.js', {
12
24
  paths: [process.cwd(), dirname(fileURLToPath(import.meta.url))]
@@ -30,7 +42,9 @@ function mergeXmlFiles(xmlPaths) {
30
42
  return '<templates>' + templates + '</templates>';
31
43
  }
32
44
  export async function metaowlPlugin(options = {}) {
33
- const { root = 'src', outDir = '../dist', publicDir = '../public', componentsDir = 'src/components', pagesDir = 'src/pages', layoutsDir = 'src/layouts', frameworkEntry = './node_modules/metaowl/index.js', vendorPackages = ['@odoo/owl'], autoImport = {}, envPrefix } = options;
45
+ const rootDir = options.root ?? 'src';
46
+ const resolvedRoot = resolve(process.cwd(), rootDir);
47
+ const { outDir = '../dist', publicDir = '../public', componentsDir = sanitizeDirOption(options.componentsDir ?? 'src/components', resolvedRoot, 'src/components'), pagesDir = sanitizeDirOption(options.pagesDir ?? 'src/pages', resolvedRoot, 'src/pages'), layoutsDir = sanitizeDirOption(options.layoutsDir ?? 'src/layouts', resolvedRoot, 'src/layouts'), frameworkEntry = './node_modules/metaowl/index.js', vendorPackages = ['@odoo/owl'], autoImport = {}, envPrefix } = options;
34
48
  const componentXml = collectXml(`${componentsDir}/**/*.xml`);
35
49
  const pageXml = collectXml(`${pagesDir}/**/*.xml`);
36
50
  const layoutXml = collectXml(`${layoutsDir}/**/*.xml`);
@@ -76,7 +90,7 @@ export async function metaowlPlugin(options = {}) {
76
90
  COMPONENTS: JSON.stringify(isDev ? allComponents : ['/templates.xml']),
77
91
  'process.env': safeEnv
78
92
  };
79
- cfg.root = cfg.root ?? root;
93
+ cfg.root = cfg.root ?? rootDir;
80
94
  cfg.publicDir = cfg.publicDir ?? publicDir;
81
95
  cfg.appType = cfg.appType ?? 'spa';
82
96
  const owlPath = resolveOwlPath();
@@ -94,7 +108,7 @@ export async function metaowlPlugin(options = {}) {
94
108
  chunkSizeWarningLimit: 1024,
95
109
  target: 'esnext',
96
110
  rollupOptions: {
97
- input: resolve(root, 'index.html'),
111
+ input: resolve(rootDir, 'index.html'),
98
112
  output: {
99
113
  manualChunks: {
100
114
  vendor: vendorPackages,
@@ -118,8 +132,8 @@ export async function metaowlPlugin(options = {}) {
118
132
  transform(code, id) {
119
133
  if (!id.endsWith('/metaowl.js'))
120
134
  return null;
121
- const pagesRel = pagesDir.replace(new RegExp(`^${root}[\\/]`), '');
122
- const layoutsRel = layoutsDir.replace(new RegExp(`^${root}[\\/]`), '');
135
+ const pagesRel = pagesDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
136
+ const layoutsRel = layoutsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
123
137
  return {
124
138
  code: code.replace(/boot\(\s*\)/, `boot(import.meta.glob('./${pagesRel}/**/*.js', { eager: true }), import.meta.glob('./${layoutsRel}/**/*.js', { eager: true }))`),
125
139
  map: null
@@ -131,9 +145,9 @@ export async function metaowlPlugin(options = {}) {
131
145
  transform(code, id) {
132
146
  if (!id.endsWith('/css.js'))
133
147
  return null;
134
- const compRel = componentsDir.replace(new RegExp(`^${root}[\\/]`), '');
135
- const pagesRel = pagesDir.replace(new RegExp(`^${root}[\\/]`), '');
136
- const layoutsRel = layoutsDir.replace(new RegExp(`^${root}[\\/]`), '');
148
+ const compRel = componentsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
149
+ const pagesRel = pagesDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
150
+ const layoutsRel = layoutsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
137
151
  return {
138
152
  code: code + '\n' +
139
153
  `import.meta.glob('/${compRel}/**/*.{css,scss}', { eager: true })\n` +
@@ -162,7 +176,7 @@ export async function metaowlPlugin(options = {}) {
162
176
  writeFileSync(file, content.replace(/\/templates\.xml/g, `/${hashedFilename}`), 'utf-8');
163
177
  }
164
178
  }
165
- const srcImages = resolve(projectRoot, root, 'assets', 'images');
179
+ const srcImages = resolve(projectRoot, rootDir, 'assets', 'images');
166
180
  if (existsSync(srcImages)) {
167
181
  cpSync(srcImages, resolve(outDirResolved, 'assets', 'images'), { recursive: true });
168
182
  }
package/package.json CHANGED
@@ -1,7 +1,17 @@
1
1
  {
2
2
  "name": "metaowl",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
+ "author": "Dennis Schott",
6
+ "license": "LGPL-3.0-only",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dennisschott/metaowl"
10
+ },
11
+ "homepage": "https://metaowl.org",
12
+ "bugs": {
13
+ "url": "https://github.com/dennisschott/metaowl/issues"
14
+ },
5
15
  "type": "module",
6
16
  "main": "./build/runtime/index.js",
7
17
  "exports": {
@@ -17,6 +27,21 @@
17
27
  "metaowl-generate": "./build/runtime/bin/metaowl-generate.js",
18
28
  "metaowl-lint": "./build/runtime/bin/metaowl-lint.js"
19
29
  },
30
+ "scripts": {
31
+ "clean:runtime": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('build/runtime', { recursive: true, force: true })\"",
32
+ "test": "vitest run",
33
+ "test:watch": "vitest",
34
+ "lint": "eslint . --ext .js,.ts",
35
+ "typecheck": "tsc -p tsconfig.json --noEmit",
36
+ "release:check": "npm run typecheck && npm test",
37
+ "build:runtime": "npm run clean:runtime && tsc -p tsconfig.build.json && node --input-type=module -e \"import { chmodSync, readdirSync } from 'node:fs'; for (const entry of readdirSync('build/runtime/bin')) { if (entry.endsWith('.js')) chmodSync('build/runtime/bin/' + entry, 0o755); }\"",
38
+ "release:build": "npm run release:check && npm run build:runtime",
39
+ "release:pack": "npm run release:check && npm pack --dry-run",
40
+ "prepack": "npm run build:runtime"
41
+ },
42
+ "engines": {
43
+ "node": ">=20.0.0"
44
+ },
20
45
  "files": [
21
46
  "build/runtime",
22
47
  "config",
@@ -33,17 +58,7 @@
33
58
  "vite",
34
59
  "meta-framework"
35
60
  ],
36
- "author": "Dennis Schott",
37
- "license": "LGPL-3.0-only",
38
61
  "sideEffects": false,
39
- "repository": {
40
- "type": "git",
41
- "url": "https://github.com/dennisschott/metaowl"
42
- },
43
- "homepage": "https://metaowl.org",
44
- "bugs": {
45
- "url": "https://github.com/dennisschott/metaowl/issues"
46
- },
47
62
  "dependencies": {
48
63
  "@eslint/js": "^9.20.1",
49
64
  "@odoo/owl": "^2.8.2",
@@ -60,21 +75,6 @@
60
75
  "vite": "^8.0.0",
61
76
  "vite-tsconfig-paths": "^6.1.1"
62
77
  },
63
- "engines": {
64
- "node": ">=20.0.0"
65
- },
66
- "scripts": {
67
- "clean:runtime": "node --input-type=module -e \"import { rmSync } from 'node:fs'; rmSync('build/runtime', { recursive: true, force: true })\"",
68
- "test": "vitest run",
69
- "test:watch": "vitest",
70
- "lint": "eslint . --ext .js,.ts",
71
- "typecheck": "tsc -p tsconfig.json --noEmit",
72
- "release:check": "npm run typecheck && npm test",
73
- "build:runtime": "npm run clean:runtime && tsc -p tsconfig.build.json && node --input-type=module -e \"import { chmodSync, readdirSync } from 'node:fs'; for (const entry of readdirSync('build/runtime/bin')) { if (entry.endsWith('.js')) chmodSync('build/runtime/bin/' + entry, 0o755); }\"",
74
- "release:build": "npm run release:check && npm run build:runtime",
75
- "release:pack": "npm run release:check && npm pack --dry-run",
76
- "prepack": "npm run build:runtime"
77
- },
78
78
  "devDependencies": {
79
79
  "@types/jsdom": "^28.0.1",
80
80
  "@types/node": "^25.6.0",