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.
- package/CHANGELOG.md +50 -0
- package/README.md +267 -2
- package/build/runtime/bin/metaowl-build.js +10 -0
- package/{bin → build/runtime/bin}/metaowl-create.js +96 -177
- package/build/runtime/bin/metaowl-dev.js +10 -0
- package/build/runtime/bin/metaowl-generate.js +231 -0
- package/build/runtime/bin/metaowl-lint.js +58 -0
- package/build/runtime/bin/utils.js +68 -0
- package/build/runtime/index.js +144 -0
- package/build/runtime/modules/app-mounter.js +73 -0
- package/build/runtime/modules/auto-import.js +140 -0
- package/build/runtime/modules/cache.js +49 -0
- package/build/runtime/modules/composables.js +353 -0
- package/build/runtime/modules/constants.js +38 -0
- package/build/runtime/modules/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +207 -0
- package/build/runtime/modules/fonts.js +172 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +180 -0
- package/build/runtime/modules/image.js +175 -0
- package/build/runtime/modules/layouts.js +214 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +265 -0
- package/build/runtime/modules/pwa.js +272 -0
- package/build/runtime/modules/router.js +384 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +198 -0
- package/build/runtime/modules/templates-manager.js +52 -0
- package/build/runtime/modules/test-utils.js +238 -0
- package/build/runtime/vite/plugin.js +197 -0
- package/eslint.js +29 -0
- package/package.json +45 -27
- package/CONTRIBUTING.md +0 -49
- package/bin/metaowl-build.js +0 -12
- package/bin/metaowl-dev.js +0 -12
- package/bin/metaowl-generate.js +0 -339
- package/bin/metaowl-lint.js +0 -71
- package/bin/utils.js +0 -82
- package/eslint.config.js +0 -3
- package/index.js +0 -328
- package/modules/app-mounter.js +0 -104
- package/modules/auto-import.js +0 -225
- package/modules/cache.js +0 -59
- package/modules/composables.js +0 -600
- package/modules/error-boundary.js +0 -228
- package/modules/fetch.js +0 -51
- package/modules/file-router.js +0 -478
- package/modules/forms.js +0 -353
- package/modules/i18n.js +0 -333
- package/modules/layouts.js +0 -431
- package/modules/link.js +0 -255
- package/modules/meta.js +0 -119
- package/modules/odoo-rpc.js +0 -511
- package/modules/pwa.js +0 -515
- package/modules/router.js +0 -769
- package/modules/seo.js +0 -501
- package/modules/store.js +0 -409
- package/modules/templates-manager.js +0 -89
- package/modules/test-utils.js +0 -532
- package/test/auto-import.test.js +0 -110
- package/test/cache.test.js +0 -55
- package/test/composables.test.js +0 -103
- package/test/dynamic-routes.test.js +0 -469
- package/test/error-boundary.test.js +0 -126
- package/test/fetch.test.js +0 -100
- package/test/file-router.test.js +0 -55
- package/test/forms.test.js +0 -203
- package/test/i18n.test.js +0 -188
- package/test/layouts.test.js +0 -395
- package/test/link.test.js +0 -189
- package/test/meta.test.js +0 -146
- package/test/odoo-rpc.test.js +0 -547
- package/test/pwa.test.js +0 -154
- package/test/router-guards.test.js +0 -229
- package/test/router.test.js +0 -77
- package/test/seo.test.js +0 -353
- package/test/store.test.js +0 -476
- package/test/templates-manager.test.js +0 -83
- package/test/test-utils.test.js +0 -314
- package/vite/plugin.js +0 -290
- 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;
|