metaowl 0.4.1 → 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.
Files changed (83) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +267 -2
  3. package/build/runtime/bin/metaowl-build.js +10 -0
  4. package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
  5. package/build/runtime/bin/metaowl-dev.js +10 -0
  6. package/build/runtime/bin/metaowl-generate.js +231 -0
  7. package/build/runtime/bin/metaowl-lint.js +58 -0
  8. package/build/runtime/bin/utils.js +68 -0
  9. package/build/runtime/index.js +144 -0
  10. package/build/runtime/modules/app-mounter.js +73 -0
  11. package/build/runtime/modules/auto-import.js +140 -0
  12. package/build/runtime/modules/cache.js +49 -0
  13. package/build/runtime/modules/composables.js +353 -0
  14. package/build/runtime/modules/constants.js +38 -0
  15. package/build/runtime/modules/error-boundary.js +116 -0
  16. package/build/runtime/modules/fetch.js +31 -0
  17. package/build/runtime/modules/file-router.js +207 -0
  18. package/build/runtime/modules/fonts.js +172 -0
  19. package/build/runtime/modules/forms.js +193 -0
  20. package/build/runtime/modules/i18n.js +180 -0
  21. package/build/runtime/modules/image.js +175 -0
  22. package/build/runtime/modules/layouts.js +214 -0
  23. package/build/runtime/modules/link.js +141 -0
  24. package/build/runtime/modules/meta.js +117 -0
  25. package/build/runtime/modules/odoo-rpc.js +265 -0
  26. package/build/runtime/modules/pwa.js +272 -0
  27. package/build/runtime/modules/router.js +384 -0
  28. package/build/runtime/modules/seo.js +186 -0
  29. package/build/runtime/modules/store.js +198 -0
  30. package/build/runtime/modules/templates-manager.js +52 -0
  31. package/build/runtime/modules/test-utils.js +238 -0
  32. package/build/runtime/vite/plugin.js +197 -0
  33. package/eslint.js +29 -0
  34. package/package.json +45 -27
  35. package/CONTRIBUTING.md +0 -49
  36. package/bin/metaowl-build.js +0 -12
  37. package/bin/metaowl-dev.js +0 -12
  38. package/bin/metaowl-generate.js +0 -339
  39. package/bin/metaowl-lint.js +0 -71
  40. package/bin/utils.js +0 -82
  41. package/eslint.config.js +0 -3
  42. package/index.js +0 -328
  43. package/modules/app-mounter.js +0 -104
  44. package/modules/auto-import.js +0 -225
  45. package/modules/cache.js +0 -59
  46. package/modules/composables.js +0 -600
  47. package/modules/error-boundary.js +0 -228
  48. package/modules/fetch.js +0 -51
  49. package/modules/file-router.js +0 -478
  50. package/modules/forms.js +0 -353
  51. package/modules/i18n.js +0 -333
  52. package/modules/layouts.js +0 -431
  53. package/modules/link.js +0 -255
  54. package/modules/meta.js +0 -119
  55. package/modules/odoo-rpc.js +0 -511
  56. package/modules/pwa.js +0 -515
  57. package/modules/router.js +0 -769
  58. package/modules/seo.js +0 -501
  59. package/modules/store.js +0 -409
  60. package/modules/templates-manager.js +0 -89
  61. package/modules/test-utils.js +0 -532
  62. package/test/auto-import.test.js +0 -110
  63. package/test/cache.test.js +0 -55
  64. package/test/composables.test.js +0 -103
  65. package/test/dynamic-routes.test.js +0 -469
  66. package/test/error-boundary.test.js +0 -126
  67. package/test/fetch.test.js +0 -100
  68. package/test/file-router.test.js +0 -55
  69. package/test/forms.test.js +0 -203
  70. package/test/i18n.test.js +0 -188
  71. package/test/layouts.test.js +0 -395
  72. package/test/link.test.js +0 -189
  73. package/test/meta.test.js +0 -146
  74. package/test/odoo-rpc.test.js +0 -547
  75. package/test/pwa.test.js +0 -154
  76. package/test/router-guards.test.js +0 -229
  77. package/test/router.test.js +0 -77
  78. package/test/seo.test.js +0 -353
  79. package/test/store.test.js +0 -476
  80. package/test/templates-manager.test.js +0 -83
  81. package/test/test-utils.test.js +0 -314
  82. package/vite/plugin.js +0 -290
  83. package/vitest.config.js +0 -8
@@ -0,0 +1,180 @@
1
+ /**
2
+ * @module i18n
3
+ *
4
+ * Internationalization (i18n) support for MetaOwl applications.
5
+ */
6
+ import { reactive } from '@odoo/owl';
7
+ const state = reactive({
8
+ locale: 'en',
9
+ fallbackLocale: 'en',
10
+ messages: {},
11
+ loading: false
12
+ });
13
+ const pluralRules = new Map();
14
+ export function setPluralizationRule(locale, rule) {
15
+ pluralRules.set(locale, rule);
16
+ }
17
+ function defaultPluralRule(count) {
18
+ if (count === 0)
19
+ return 'zero';
20
+ if (count === 1)
21
+ return 'one';
22
+ return 'other';
23
+ }
24
+ function getPluralForm(count, locale) {
25
+ const rule = pluralRules.get(locale) || defaultPluralRule;
26
+ return rule(count);
27
+ }
28
+ export function configureI18n(config) {
29
+ if (config.locale) {
30
+ state.locale = config.locale;
31
+ document.documentElement.lang = config.locale;
32
+ }
33
+ if (config.fallbackLocale) {
34
+ state.fallbackLocale = config.fallbackLocale;
35
+ }
36
+ if (config.messages) {
37
+ state.messages = config.messages;
38
+ }
39
+ }
40
+ export function getLocale() {
41
+ return state.locale;
42
+ }
43
+ export async function setLocale(locale) {
44
+ state.locale = locale;
45
+ document.documentElement.lang = locale;
46
+ }
47
+ export async function loadLocaleMessages(locale, messages) {
48
+ state.loading = true;
49
+ try {
50
+ const loaded = await messages;
51
+ if (!state.messages[locale]) {
52
+ state.messages[locale] = {};
53
+ }
54
+ Object.assign(state.messages[locale], loaded);
55
+ }
56
+ finally {
57
+ state.loading = false;
58
+ }
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
+ }
71
+ export function t(key, values = {}, defaultMessage) {
72
+ const locale = state.locale;
73
+ const fallbackLocale = state.fallbackLocale;
74
+ let message = getMessage(key, locale);
75
+ if (!message && locale !== fallbackLocale) {
76
+ message = getMessage(key, fallbackLocale);
77
+ }
78
+ if (!message) {
79
+ return defaultMessage || key;
80
+ }
81
+ if (isPluralMessage(message)) {
82
+ const countValue = values.n ?? values.count ?? 0;
83
+ const count = typeof countValue === 'number' ? countValue : Number(countValue);
84
+ const form = getPluralForm(Number.isNaN(count) ? 0 : count, locale);
85
+ message = message[form] || message.other || message.one || key;
86
+ }
87
+ if (typeof message !== 'string') {
88
+ return defaultMessage || key;
89
+ }
90
+ return interpolate(message, values);
91
+ }
92
+ function getMessage(key, locale) {
93
+ const parts = key.split('.');
94
+ let current = state.messages[locale];
95
+ for (const part of parts) {
96
+ if (!current || typeof current !== 'object') {
97
+ return undefined;
98
+ }
99
+ current = current[part];
100
+ }
101
+ return current;
102
+ }
103
+ function isPluralMessage(message) {
104
+ if (!message || typeof message !== 'object' || Array.isArray(message)) {
105
+ return false;
106
+ }
107
+ const keys = Object.keys(message);
108
+ return keys.some((key) => ['zero', 'one', 'two', 'few', 'many', 'other'].includes(key));
109
+ }
110
+ function interpolate(message, values) {
111
+ return message.replace(/\{\{(\w+)\}\}/g, (match, key) => {
112
+ return values[key] !== undefined ? String(values[key]) : match;
113
+ });
114
+ }
115
+ export function formatDate(date, options = {}) {
116
+ return new Intl.DateTimeFormat(state.locale, options).format(new Date(date));
117
+ }
118
+ export function formatNumber(value, options = {}) {
119
+ return new Intl.NumberFormat(state.locale, options).format(value);
120
+ }
121
+ export function formatCurrency(amount, currency, options = {}) {
122
+ return new Intl.NumberFormat(state.locale, {
123
+ style: 'currency',
124
+ currency,
125
+ ...options
126
+ }).format(amount);
127
+ }
128
+ export function formatRelativeTime(date, style = 'long') {
129
+ const targetDate = new Date(date);
130
+ const now = new Date();
131
+ const diff = targetDate.getTime() - now.getTime();
132
+ const seconds = Math.round(diff / 1000);
133
+ const minutes = Math.round(seconds / 60);
134
+ const hours = Math.round(minutes / 60);
135
+ const days = Math.round(hours / 24);
136
+ const rtf = new Intl.RelativeTimeFormat(state.locale, { style });
137
+ if (Math.abs(seconds) < 60)
138
+ return rtf.format(seconds, 'second');
139
+ if (Math.abs(minutes) < 60)
140
+ return rtf.format(minutes, 'minute');
141
+ if (Math.abs(hours) < 24)
142
+ return rtf.format(hours, 'hour');
143
+ return rtf.format(days, 'day');
144
+ }
145
+ export const i18n = {
146
+ get locale() { return state.locale; },
147
+ get fallbackLocale() { return state.fallbackLocale; },
148
+ get loading() { return state.loading; },
149
+ get messages() { return state.messages; },
150
+ configure: configureI18n,
151
+ setLocale,
152
+ load,
153
+ loadLocaleMessages,
154
+ t,
155
+ formatDate,
156
+ formatNumber,
157
+ formatCurrency,
158
+ formatRelativeTime
159
+ };
160
+ export function createNamespacedT(namespace) {
161
+ return (key, values) => t(`${namespace}.${key}`, values);
162
+ }
163
+ setPluralizationRule('de', (count) => {
164
+ if (count === 0)
165
+ return 'zero';
166
+ if (count === 1)
167
+ return 'one';
168
+ return 'other';
169
+ });
170
+ setPluralizationRule('ru', (count) => {
171
+ const mod10 = count % 10;
172
+ const mod100 = count % 100;
173
+ if (mod10 === 1 && mod100 !== 11)
174
+ return 'one';
175
+ if ([2, 3, 4].includes(mod10) && ![12, 13, 14].includes(mod100))
176
+ return 'few';
177
+ if (mod10 === 0 || [5, 6, 7, 8, 9].includes(mod10) || [11, 12, 13, 14].includes(mod100))
178
+ return 'many';
179
+ return 'other';
180
+ });
@@ -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
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @module Layouts
3
+ *
4
+ * Layout system for OWL applications, enabling shared page structures.
5
+ */
6
+ import { Component, mount, xml } from '@odoo/owl';
7
+ const layouts = new Map();
8
+ let defaultLayout = 'default';
9
+ let currentLayout = null;
10
+ const listeners = [];
11
+ const routeLayouts = new Map();
12
+ export function registerLayout(name, layoutComponent, options = {}) {
13
+ layouts.set(name, layoutComponent);
14
+ if (options.default) {
15
+ defaultLayout = name;
16
+ }
17
+ for (const listener of listeners) {
18
+ listener({ type: 'register', name, layout: layoutComponent });
19
+ }
20
+ }
21
+ export function unregisterLayout(name) {
22
+ const removed = layouts.delete(name);
23
+ if (removed) {
24
+ for (const listener of listeners) {
25
+ listener({ type: 'unregister', name });
26
+ }
27
+ }
28
+ return removed;
29
+ }
30
+ export function getLayout(name) {
31
+ return layouts.get(name);
32
+ }
33
+ export function hasLayout(name) {
34
+ return layouts.has(name);
35
+ }
36
+ export function getLayoutNames() {
37
+ return Array.from(layouts.keys());
38
+ }
39
+ export function setDefaultLayout(name) {
40
+ if (!layouts.has(name)) {
41
+ console.warn(`[metaowl] Layout "${name}" is not registered yet`);
42
+ }
43
+ defaultLayout = name;
44
+ }
45
+ export function getDefaultLayout() {
46
+ return defaultLayout;
47
+ }
48
+ export function resolveLayout(component, routePath) {
49
+ if (routePath && routeLayouts.has(routePath)) {
50
+ return routeLayouts.get(routePath);
51
+ }
52
+ if (component.layout) {
53
+ return component.layout;
54
+ }
55
+ if (component._layout) {
56
+ return component._layout;
57
+ }
58
+ return defaultLayout;
59
+ }
60
+ export function setRouteLayout(routePath, layoutName) {
61
+ routeLayouts.set(routePath, layoutName);
62
+ }
63
+ export function getRouteLayout(routePath) {
64
+ return routeLayouts.get(routePath);
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
+ }
92
+ export function createLayoutWrapper(layoutComponent, pageComponent, props = {}) {
93
+ const LayoutClass = layoutComponent;
94
+ const PageClass = pageComponent;
95
+ return class LayoutWrapper extends Component {
96
+ static template = xml `
97
+ <t t-component="layout" t-props="layoutProps">
98
+ <t t-component="page" t-props="pageProps"/>
99
+ </t>
100
+ `;
101
+ layout;
102
+ page;
103
+ layoutProps;
104
+ pageProps;
105
+ setup() {
106
+ this.layout = LayoutClass;
107
+ this.page = PageClass;
108
+ this.layoutProps = {};
109
+ this.pageProps = props;
110
+ }
111
+ };
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
+ }
123
+ export async function mountWithLayout(pageComponent, target, options = {}, config = {}) {
124
+ const { routePath, props = {}, templates } = options;
125
+ const layoutName = resolveLayout(pageComponent, routePath);
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) {
135
+ console.warn(`[metaowl] Layout "${layoutName}" not found, mounting page without layout`);
136
+ return await mount(pageComponent, target, { ...config, props, templates });
137
+ }
138
+ const WrapperClass = createNestedLayoutWrapper(layoutClasses, pageComponent, props);
139
+ const instance = await mount(WrapperClass, target, { ...config, templates });
140
+ currentLayout = instance;
141
+ for (const listener of listeners) {
142
+ listener({ type: 'mount', layout: layoutName, page: pageComponent.name });
143
+ }
144
+ return instance;
145
+ }
146
+ export function getCurrentLayout() {
147
+ return currentLayout;
148
+ }
149
+ export function subscribeToLayouts(callback) {
150
+ listeners.push(callback);
151
+ return () => {
152
+ const index = listeners.indexOf(callback);
153
+ if (index > -1)
154
+ listeners.splice(index, 1);
155
+ };
156
+ }
157
+ export function clearLayouts() {
158
+ layouts.clear();
159
+ routeLayouts.clear();
160
+ listeners.length = 0;
161
+ defaultLayout = 'default';
162
+ currentLayout = null;
163
+ }
164
+ export function layout(name) {
165
+ return function decorator(componentClass) {
166
+ componentClass.layout = name;
167
+ return componentClass;
168
+ };
169
+ }
170
+ export function defineLayout(name, options = {}) {
171
+ return function decorator(componentClass) {
172
+ componentClass.layout = name;
173
+ componentClass.layoutOptions = options;
174
+ return componentClass;
175
+ };
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
+ }
185
+ export function buildLayouts(modules) {
186
+ const discoveredLayouts = {};
187
+ for (const [key, mod] of Object.entries(modules)) {
188
+ const match = key.match(/\.\/layouts\/([^/]+)/);
189
+ if (!match)
190
+ continue;
191
+ const layoutName = match[1];
192
+ const componentClass = resolveLayoutComponent(mod);
193
+ if (componentClass) {
194
+ discoveredLayouts[layoutName] = componentClass;
195
+ registerLayout(layoutName, componentClass);
196
+ }
197
+ }
198
+ return discoveredLayouts;
199
+ }
200
+ export async function discoverLayouts(options = {}) {
201
+ const { defaultLayout: nextDefaultLayout = 'default' } = options;
202
+ const modules = import.meta.glob('./layouts/**/*.js', { eager: true });
203
+ const discoveredLayouts = buildLayouts(modules);
204
+ if (discoveredLayouts[nextDefaultLayout]) {
205
+ setDefaultLayout(nextDefaultLayout);
206
+ }
207
+ return discoveredLayouts;
208
+ }
209
+ function resolveLayoutComponent(mod) {
210
+ if (typeof mod.default === 'function') {
211
+ return mod.default;
212
+ }
213
+ return Object.values(mod).find((value) => typeof value === 'function');
214
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * @module Link
3
+ *
4
+ * SPA Link component for metaowl with automatic external link detection.
5
+ */
6
+ import { Component, onMounted, onWillUnmount, useState } from '@odoo/owl';
7
+ import { EXTERNAL_URL_REGEX } from './constants.js';
8
+ function isExternalUrl(url) {
9
+ if (!url || typeof url !== 'string')
10
+ return false;
11
+ return EXTERNAL_URL_REGEX.test(url);
12
+ }
13
+ function isActiveLink(linkPath, currentPath) {
14
+ if (!linkPath || !currentPath)
15
+ return false;
16
+ const normalizedLink = linkPath.replace(/\/$/, '') || '/';
17
+ const normalizedCurrent = currentPath.replace(/\/$/, '') || '/';
18
+ return normalizedCurrent === normalizedLink ||
19
+ (normalizedLink !== '/' && normalizedCurrent.startsWith(normalizedLink + '/'));
20
+ }
21
+ export class Link extends Component {
22
+ static template = 'Link';
23
+ static props = {
24
+ to: { type: String, optional: false },
25
+ class: { type: String, optional: true },
26
+ activeClass: { type: String, optional: true },
27
+ target: { type: String, optional: true },
28
+ rel: { type: String, optional: true },
29
+ title: { type: String, optional: true },
30
+ download: { type: [String, Boolean], optional: true },
31
+ hreflang: { type: String, optional: true },
32
+ type: { type: String, optional: true },
33
+ ping: { type: String, optional: true },
34
+ referrerpolicy: { type: String, optional: true },
35
+ media: { type: String, optional: true },
36
+ '*': true
37
+ };
38
+ state;
39
+ _navigate = null;
40
+ _updateActiveState = () => { };
41
+ setup() {
42
+ this.state = useState({
43
+ isActive: false
44
+ });
45
+ this._updateActiveState = () => {
46
+ if (this.props.activeClass) {
47
+ this.state.isActive = isActiveLink(this.props.to, document.location.pathname);
48
+ }
49
+ };
50
+ onMounted(() => {
51
+ this._updateActiveState();
52
+ window.addEventListener('popstate', this._updateActiveState);
53
+ });
54
+ onWillUnmount(() => {
55
+ window.removeEventListener('popstate', this._updateActiveState);
56
+ });
57
+ }
58
+ get linkClasses() {
59
+ const classes = [];
60
+ if (this.props.class) {
61
+ classes.push(this.props.class);
62
+ }
63
+ if (this.state.isActive && this.props.activeClass) {
64
+ classes.push(this.props.activeClass);
65
+ }
66
+ return classes.join(' ');
67
+ }
68
+ get linkRel() {
69
+ if (this.props.rel)
70
+ return this.props.rel;
71
+ if (isExternalUrl(this.props.to) && this.props.target === '_blank') {
72
+ return 'noopener noreferrer';
73
+ }
74
+ return undefined;
75
+ }
76
+ get forwardedAttrs() {
77
+ const attrs = { ...this.props };
78
+ delete attrs.to;
79
+ delete attrs.class;
80
+ delete attrs.activeClass;
81
+ delete attrs.target;
82
+ delete attrs.rel;
83
+ delete attrs.title;
84
+ delete attrs.download;
85
+ return attrs;
86
+ }
87
+ onClick(ev) {
88
+ const url = this.props.to;
89
+ if (isExternalUrl(url)) {
90
+ return;
91
+ }
92
+ if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.shiftKey) {
93
+ return;
94
+ }
95
+ if (ev.button !== 0) {
96
+ return;
97
+ }
98
+ if (this.props.download) {
99
+ return;
100
+ }
101
+ ev.preventDefault();
102
+ window.history.pushState({ path: url }, '', url);
103
+ if (typeof window.__metaowlNavigate === 'function') {
104
+ window.__metaowlNavigate(url);
105
+ }
106
+ else {
107
+ window.location.href = url;
108
+ }
109
+ }
110
+ }
111
+ export const LinkTemplate = /* xml */ `
112
+ <templates>
113
+ <t t-name="Link">
114
+ <a
115
+ t-att="forwardedAttrs"
116
+ t-att-href="props.to"
117
+ t-att-class="linkClasses"
118
+ t-att-target="props.target"
119
+ t-att-rel="linkRel"
120
+ t-att-title="props.title"
121
+ t-att-download="props.download"
122
+ t-on-click="onClick"
123
+ >
124
+ <t t-slot="default"/>
125
+ </a>
126
+ </t>
127
+ </templates>
128
+ `;
129
+ export function registerLinkTemplate(templates) {
130
+ if (typeof templates === 'string') {
131
+ const linkContent = LinkTemplate
132
+ .replace('<templates>', '')
133
+ .replace('</templates>', '')
134
+ .trim();
135
+ return templates.replace('</templates>', linkContent + '\n</templates>');
136
+ }
137
+ if (templates && typeof templates === 'object') {
138
+ templates.Link = LinkTemplate;
139
+ }
140
+ }
141
+ export default Link;