metaowl 0.4.1 → 0.5.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 +22 -0
- package/README.md +12 -0
- 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 +141 -0
- package/build/runtime/modules/app-mounter.js +65 -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/error-boundary.js +116 -0
- package/build/runtime/modules/fetch.js +31 -0
- package/build/runtime/modules/file-router.js +205 -0
- package/build/runtime/modules/forms.js +193 -0
- package/build/runtime/modules/i18n.js +167 -0
- package/build/runtime/modules/layouts.js +163 -0
- package/build/runtime/modules/link.js +141 -0
- package/build/runtime/modules/meta.js +117 -0
- package/build/runtime/modules/odoo-rpc.js +264 -0
- package/build/runtime/modules/pwa.js +262 -0
- package/build/runtime/modules/router.js +389 -0
- package/build/runtime/modules/seo.js +186 -0
- package/build/runtime/modules/store.js +196 -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 +183 -0
- package/eslint.js +29 -0
- package/package.json +28 -10
- 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,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module SEO
|
|
3
|
+
*
|
|
4
|
+
* SEO utilities for MetaOwl applications.
|
|
5
|
+
*/
|
|
6
|
+
const VALID_CHANGE_FREQUENCIES = ['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never'];
|
|
7
|
+
export function generateSitemap(entries, options = {}) {
|
|
8
|
+
const { baseUrl } = options;
|
|
9
|
+
if (!baseUrl) {
|
|
10
|
+
throw new Error('[SEO] baseUrl is required for sitemap generation');
|
|
11
|
+
}
|
|
12
|
+
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
13
|
+
const urls = entries.map((entry) => {
|
|
14
|
+
const routeUrl = entry.url ?? '';
|
|
15
|
+
const location = routeUrl.startsWith('http')
|
|
16
|
+
? routeUrl
|
|
17
|
+
: `${normalizedBase}${routeUrl.startsWith('/') ? routeUrl : '/' + routeUrl}`;
|
|
18
|
+
let urlXml = ` <url>\n <loc>${escapeXml(location)}</loc>\n`;
|
|
19
|
+
if (entry.lastmod) {
|
|
20
|
+
urlXml += ` <lastmod>${entry.lastmod}</lastmod>\n`;
|
|
21
|
+
}
|
|
22
|
+
if (entry.changefreq && VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
|
|
23
|
+
urlXml += ` <changefreq>${entry.changefreq}</changefreq>\n`;
|
|
24
|
+
}
|
|
25
|
+
if (entry.priority !== undefined) {
|
|
26
|
+
const priority = Math.max(0, Math.min(1, entry.priority)).toFixed(1);
|
|
27
|
+
urlXml += ` <priority>${priority}</priority>\n`;
|
|
28
|
+
}
|
|
29
|
+
if (entry.image) {
|
|
30
|
+
urlXml += ' <image:image xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n';
|
|
31
|
+
urlXml += ` <image:loc>${escapeXml(entry.image)}</image:loc>\n`;
|
|
32
|
+
urlXml += ' </image:image>\n';
|
|
33
|
+
}
|
|
34
|
+
urlXml += ' </url>';
|
|
35
|
+
return urlXml;
|
|
36
|
+
});
|
|
37
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls.join('\n')}\n</urlset>`;
|
|
38
|
+
}
|
|
39
|
+
export function generateRobotsTxt(config = {}) {
|
|
40
|
+
const configs = Array.isArray(config) ? config : [config];
|
|
41
|
+
const sections = configs.map((cfg) => {
|
|
42
|
+
const { userAgent = '*', allow = [], disallow = [], crawlDelay } = cfg;
|
|
43
|
+
let section = `User-agent: ${userAgent}\n`;
|
|
44
|
+
for (const path of allow) {
|
|
45
|
+
section += `Allow: ${path}\n`;
|
|
46
|
+
}
|
|
47
|
+
for (const path of disallow) {
|
|
48
|
+
section += `Disallow: ${path}\n`;
|
|
49
|
+
}
|
|
50
|
+
if (crawlDelay !== undefined && crawlDelay > 0) {
|
|
51
|
+
section += `Crawl-delay: ${crawlDelay}\n`;
|
|
52
|
+
}
|
|
53
|
+
return section.trim();
|
|
54
|
+
});
|
|
55
|
+
const globalConfig = configs.find((cfg) => cfg.sitemap || cfg.host);
|
|
56
|
+
if (globalConfig?.sitemap) {
|
|
57
|
+
sections.push(`Sitemap: ${globalConfig.sitemap}`);
|
|
58
|
+
}
|
|
59
|
+
if (globalConfig?.host) {
|
|
60
|
+
sections.push(`Host: ${globalConfig.host}`);
|
|
61
|
+
}
|
|
62
|
+
return sections.join('\n\n');
|
|
63
|
+
}
|
|
64
|
+
export function jsonLd(schema) {
|
|
65
|
+
const fullSchema = {
|
|
66
|
+
'@context': 'https://schema.org',
|
|
67
|
+
...schema
|
|
68
|
+
};
|
|
69
|
+
return JSON.stringify(fullSchema, null, 2);
|
|
70
|
+
}
|
|
71
|
+
export function createCanonicalUrl(baseUrl, path, options = {}) {
|
|
72
|
+
const { removeQueryParams = false, allowedParams = [] } = options;
|
|
73
|
+
const normalizedBase = baseUrl.replace(/\/$/, '');
|
|
74
|
+
const [pathname, queryString] = path.split('?');
|
|
75
|
+
const normalizedPath = pathname.startsWith('/') ? pathname : '/' + pathname;
|
|
76
|
+
if (!queryString || removeQueryParams) {
|
|
77
|
+
return `${normalizedBase}${normalizedPath}`;
|
|
78
|
+
}
|
|
79
|
+
if (allowedParams.length > 0) {
|
|
80
|
+
const params = new URLSearchParams(queryString);
|
|
81
|
+
const filtered = new URLSearchParams();
|
|
82
|
+
for (const key of allowedParams) {
|
|
83
|
+
const value = params.get(key);
|
|
84
|
+
if (value !== null) {
|
|
85
|
+
filtered.set(key, value);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const filteredQuery = filtered.toString();
|
|
89
|
+
return filteredQuery
|
|
90
|
+
? `${normalizedBase}${normalizedPath}?${filteredQuery}`
|
|
91
|
+
: `${normalizedBase}${normalizedPath}`;
|
|
92
|
+
}
|
|
93
|
+
return `${normalizedBase}${normalizedPath}?${queryString}`;
|
|
94
|
+
}
|
|
95
|
+
export function generateOpenGraph(data) {
|
|
96
|
+
const { title, description, type = 'website', url, image, siteName } = data;
|
|
97
|
+
const tags = {
|
|
98
|
+
'og:title': title,
|
|
99
|
+
'og:type': type
|
|
100
|
+
};
|
|
101
|
+
if (description)
|
|
102
|
+
tags['og:description'] = description;
|
|
103
|
+
if (url)
|
|
104
|
+
tags['og:url'] = url;
|
|
105
|
+
if (image)
|
|
106
|
+
tags['og:image'] = image;
|
|
107
|
+
if (siteName)
|
|
108
|
+
tags['og:site_name'] = siteName;
|
|
109
|
+
return tags;
|
|
110
|
+
}
|
|
111
|
+
export function generateTwitterCard(data) {
|
|
112
|
+
const { title, description, card = 'summary_large_image', image, site } = data;
|
|
113
|
+
const tags = {
|
|
114
|
+
'twitter:card': card,
|
|
115
|
+
'twitter:title': title
|
|
116
|
+
};
|
|
117
|
+
if (description)
|
|
118
|
+
tags['twitter:description'] = description;
|
|
119
|
+
if (image)
|
|
120
|
+
tags['twitter:image'] = image;
|
|
121
|
+
if (site)
|
|
122
|
+
tags['twitter:site'] = site;
|
|
123
|
+
return tags;
|
|
124
|
+
}
|
|
125
|
+
export function validateSitemap(entries) {
|
|
126
|
+
const errors = [];
|
|
127
|
+
for (let index = 0; index < entries.length; index++) {
|
|
128
|
+
const entry = entries[index];
|
|
129
|
+
if (!entry.url) {
|
|
130
|
+
errors.push(`Entry ${index}: Missing required 'url'`);
|
|
131
|
+
}
|
|
132
|
+
if (entry.priority !== undefined && (entry.priority < 0 || entry.priority > 1)) {
|
|
133
|
+
errors.push(`Entry ${index}: Priority must be between 0 and 1`);
|
|
134
|
+
}
|
|
135
|
+
if (entry.changefreq && !VALID_CHANGE_FREQUENCIES.includes(entry.changefreq)) {
|
|
136
|
+
errors.push(`Entry ${index}: Invalid changefreq '${entry.changefreq}'`);
|
|
137
|
+
}
|
|
138
|
+
if (entry.lastmod) {
|
|
139
|
+
const date = new Date(entry.lastmod);
|
|
140
|
+
if (Number.isNaN(date.getTime())) {
|
|
141
|
+
errors.push(`Entry ${index}: Invalid lastmod date '${entry.lastmod}'`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
valid: errors.length === 0,
|
|
147
|
+
errors
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
export function getPriorityByDepth(url, options = {}) {
|
|
151
|
+
const { maxDepth = 3 } = options;
|
|
152
|
+
const depth = url.split('/').filter(Boolean).length;
|
|
153
|
+
const priority = Math.max(0.1, 1 - (depth / maxDepth) * 0.3);
|
|
154
|
+
return Math.round(priority * 10) / 10;
|
|
155
|
+
}
|
|
156
|
+
export function generateSitemapIndex(sitemaps) {
|
|
157
|
+
const entries = sitemaps.map((sitemap) => {
|
|
158
|
+
let entry = ` <sitemap>\n <loc>${escapeXml(sitemap.loc)}</loc>\n`;
|
|
159
|
+
if (sitemap.lastmod) {
|
|
160
|
+
entry += ` <lastmod>${sitemap.lastmod}</lastmod>\n`;
|
|
161
|
+
}
|
|
162
|
+
entry += ' </sitemap>';
|
|
163
|
+
return entry;
|
|
164
|
+
});
|
|
165
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${entries.join('\n')}\n</sitemapindex>`;
|
|
166
|
+
}
|
|
167
|
+
function escapeXml(str) {
|
|
168
|
+
return str
|
|
169
|
+
.replace(/&/g, '&')
|
|
170
|
+
.replace(/</g, '<')
|
|
171
|
+
.replace(/>/g, '>')
|
|
172
|
+
.replace(/"/g, '"')
|
|
173
|
+
.replace(/'/g, ''');
|
|
174
|
+
}
|
|
175
|
+
export const SEO = {
|
|
176
|
+
generateSitemap,
|
|
177
|
+
generateRobotsTxt,
|
|
178
|
+
jsonLd,
|
|
179
|
+
createCanonicalUrl,
|
|
180
|
+
generateOpenGraph,
|
|
181
|
+
generateTwitterCard,
|
|
182
|
+
validateSitemap,
|
|
183
|
+
getPriorityByDepth,
|
|
184
|
+
generateSitemapIndex
|
|
185
|
+
};
|
|
186
|
+
export default SEO;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Store
|
|
3
|
+
*
|
|
4
|
+
* Lightweight state management for OWL applications, inspired by Pinia/Vuex.
|
|
5
|
+
*/
|
|
6
|
+
import { reactive } from '@odoo/owl';
|
|
7
|
+
const stores = new Map();
|
|
8
|
+
const plugins = [];
|
|
9
|
+
export class Store {
|
|
10
|
+
_id;
|
|
11
|
+
_config;
|
|
12
|
+
_state;
|
|
13
|
+
_getters;
|
|
14
|
+
_mutations;
|
|
15
|
+
_actions;
|
|
16
|
+
_subscribers;
|
|
17
|
+
_actionSubscribers;
|
|
18
|
+
constructor(id, config) {
|
|
19
|
+
this._id = id;
|
|
20
|
+
this._config = config;
|
|
21
|
+
this._state = reactive(config.state ? config.state() : {});
|
|
22
|
+
this._getters = {};
|
|
23
|
+
this._mutations = config.mutations || {};
|
|
24
|
+
this._actions = config.actions || {};
|
|
25
|
+
this._subscribers = [];
|
|
26
|
+
this._actionSubscribers = [];
|
|
27
|
+
if (config.getters) {
|
|
28
|
+
for (const [name, fn] of Object.entries(config.getters)) {
|
|
29
|
+
Object.defineProperty(this._getters, name, {
|
|
30
|
+
get: () => fn(this._state, this._getters),
|
|
31
|
+
enumerable: true,
|
|
32
|
+
configurable: true
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const plugin of plugins) {
|
|
37
|
+
plugin(this);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
get id() {
|
|
41
|
+
return this._id;
|
|
42
|
+
}
|
|
43
|
+
get state() {
|
|
44
|
+
return this._state;
|
|
45
|
+
}
|
|
46
|
+
get getters() {
|
|
47
|
+
return this._getters;
|
|
48
|
+
}
|
|
49
|
+
commit(type, payload) {
|
|
50
|
+
const mutation = this._mutations[type];
|
|
51
|
+
if (!mutation) {
|
|
52
|
+
throw new Error(`[metaowl] Mutation "${type}" not found in store "${this._id}"`);
|
|
53
|
+
}
|
|
54
|
+
const prevState = JSON.parse(JSON.stringify(this._state));
|
|
55
|
+
const result = mutation(this._state, payload);
|
|
56
|
+
for (const subscriber of this._subscribers) {
|
|
57
|
+
subscriber({ type, payload }, this._state, prevState);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
async dispatch(type, payload) {
|
|
62
|
+
const action = this._actions[type];
|
|
63
|
+
if (!action) {
|
|
64
|
+
throw new Error(`[metaowl] Action "${type}" not found in store "${this._id}"`);
|
|
65
|
+
}
|
|
66
|
+
const context = {
|
|
67
|
+
state: this._state,
|
|
68
|
+
getters: this._getters,
|
|
69
|
+
commit: this.commit.bind(this),
|
|
70
|
+
dispatch: this.dispatch.bind(this)
|
|
71
|
+
};
|
|
72
|
+
for (const subscriber of this._actionSubscribers) {
|
|
73
|
+
subscriber({ type, payload }, 'before');
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result = await action(context, payload);
|
|
77
|
+
for (const subscriber of this._actionSubscribers) {
|
|
78
|
+
subscriber({ type, payload }, 'after', result);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
for (const subscriber of this._actionSubscribers) {
|
|
84
|
+
subscriber({ type, payload }, 'error', error);
|
|
85
|
+
}
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
subscribe(callback) {
|
|
90
|
+
this._subscribers.push(callback);
|
|
91
|
+
return () => {
|
|
92
|
+
const index = this._subscribers.indexOf(callback);
|
|
93
|
+
if (index > -1)
|
|
94
|
+
this._subscribers.splice(index, 1);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
subscribeAction(callback) {
|
|
98
|
+
this._actionSubscribers.push(callback);
|
|
99
|
+
return () => {
|
|
100
|
+
const index = this._actionSubscribers.indexOf(callback);
|
|
101
|
+
if (index > -1)
|
|
102
|
+
this._actionSubscribers.splice(index, 1);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
reset() {
|
|
106
|
+
if (this._config.state) {
|
|
107
|
+
const initialState = this._config.state();
|
|
108
|
+
Object.keys(this._state).forEach((key) => {
|
|
109
|
+
delete this._state[key];
|
|
110
|
+
});
|
|
111
|
+
Object.assign(this._state, initialState);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
static define(id, config) {
|
|
115
|
+
return function useStore() {
|
|
116
|
+
if (!stores.has(id)) {
|
|
117
|
+
stores.set(id, new Store(id, config));
|
|
118
|
+
}
|
|
119
|
+
return stores.get(id);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
static get(id) {
|
|
123
|
+
return stores.get(id);
|
|
124
|
+
}
|
|
125
|
+
static has(id) {
|
|
126
|
+
return stores.has(id);
|
|
127
|
+
}
|
|
128
|
+
static remove(id) {
|
|
129
|
+
const store = stores.get(id);
|
|
130
|
+
if (store) {
|
|
131
|
+
store.reset();
|
|
132
|
+
return stores.delete(id);
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
static clear() {
|
|
137
|
+
stores.clear();
|
|
138
|
+
}
|
|
139
|
+
static storeIds() {
|
|
140
|
+
return Array.from(stores.keys());
|
|
141
|
+
}
|
|
142
|
+
static use(plugin) {
|
|
143
|
+
plugins.push(plugin);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
export function createPersistencePlugin(options = {}) {
|
|
147
|
+
const { storage = localStorage, key, paths } = options;
|
|
148
|
+
return function persistencePlugin(store) {
|
|
149
|
+
const storageKey = key || `metaowl:store:${store.id}`;
|
|
150
|
+
try {
|
|
151
|
+
const saved = storage.getItem(storageKey);
|
|
152
|
+
if (saved) {
|
|
153
|
+
const persisted = JSON.parse(saved);
|
|
154
|
+
if (paths) {
|
|
155
|
+
for (const path of paths) {
|
|
156
|
+
if (path in persisted && path in store.state) {
|
|
157
|
+
store.state[path] = persisted[path];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
Object.assign(store.state, persisted);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
console.warn('[metaowl] Failed to restore store from storage:', error);
|
|
168
|
+
}
|
|
169
|
+
store.subscribe((_mutation, state) => {
|
|
170
|
+
try {
|
|
171
|
+
const toPersist = paths
|
|
172
|
+
? Object.fromEntries(paths.map((path) => [path, state[path]]))
|
|
173
|
+
: state;
|
|
174
|
+
storage.setItem(storageKey, JSON.stringify(toPersist));
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
console.warn('[metaowl] Failed to persist store:', error);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
export function createStore(initialState = {}) {
|
|
183
|
+
const state = reactive({ ...initialState });
|
|
184
|
+
state.$patch = (partialState) => {
|
|
185
|
+
Object.assign(state, partialState);
|
|
186
|
+
};
|
|
187
|
+
state.$reset = () => {
|
|
188
|
+
Object.keys(state).forEach((key) => {
|
|
189
|
+
if (!key.startsWith('$')) {
|
|
190
|
+
delete state[key];
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
Object.assign(state, initialState);
|
|
194
|
+
};
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module TemplatesManager
|
|
3
|
+
*
|
|
4
|
+
* Template loading and merging utilities for OWL applications.
|
|
5
|
+
*/
|
|
6
|
+
import { loadFile } from '@odoo/owl';
|
|
7
|
+
const LINK_COMPONENT_TEMPLATE = /* xml */ `
|
|
8
|
+
<t t-name="Link">
|
|
9
|
+
<a
|
|
10
|
+
t-att="forwardedAttrs"
|
|
11
|
+
t-att-href="props.to"
|
|
12
|
+
t-att-class="linkClasses"
|
|
13
|
+
t-att-target="props.target"
|
|
14
|
+
t-att-rel="linkRel"
|
|
15
|
+
t-att-title="props.title"
|
|
16
|
+
t-att-download="props.download"
|
|
17
|
+
t-on-click="onClick"
|
|
18
|
+
>
|
|
19
|
+
<t t-slot="default"/>
|
|
20
|
+
</a>
|
|
21
|
+
</t>
|
|
22
|
+
`;
|
|
23
|
+
const INTERNAL_TEMPLATES = [LINK_COMPONENT_TEMPLATE];
|
|
24
|
+
export async function mergeTemplates(files) {
|
|
25
|
+
const fileArray = Array.isArray(files) ? files : [files];
|
|
26
|
+
if (fileArray.length === 1) {
|
|
27
|
+
try {
|
|
28
|
+
const content = await loadFile(fileArray[0]);
|
|
29
|
+
if (content.trim().startsWith('<templates>')) {
|
|
30
|
+
return content.replace('</templates>', INTERNAL_TEMPLATES.join('') + '</templates>');
|
|
31
|
+
}
|
|
32
|
+
return '<templates>' + content + INTERNAL_TEMPLATES.join('') + '</templates>';
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error(`[metaowl] Failed to load template: ${fileArray[0]}`, error);
|
|
36
|
+
return '<templates>' + INTERNAL_TEMPLATES.join('') + '</templates>';
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const results = await Promise.all(fileArray.map(async (file) => {
|
|
40
|
+
try {
|
|
41
|
+
return await loadFile(file);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
console.error(`[metaowl] Failed to load template: ${file}`, error);
|
|
45
|
+
return '';
|
|
46
|
+
}
|
|
47
|
+
}));
|
|
48
|
+
return '<templates>' + results.join('') + INTERNAL_TEMPLATES.join('') + '</templates>';
|
|
49
|
+
}
|
|
50
|
+
export function getInternalTemplates() {
|
|
51
|
+
return [...INTERNAL_TEMPLATES];
|
|
52
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module TestUtils
|
|
3
|
+
*
|
|
4
|
+
* Testing utilities for MetaOwl OWL applications.
|
|
5
|
+
*/
|
|
6
|
+
import { mount, reactive } from '@odoo/owl';
|
|
7
|
+
export function createMockStore(config = {}) {
|
|
8
|
+
const { state: initialState = {}, getters: getterDefs = {}, mutations: mutationDefs = {}, actions: actionDefs = {} } = config;
|
|
9
|
+
const state = reactive({ ...initialState });
|
|
10
|
+
const getters = {};
|
|
11
|
+
for (const [name, fn] of Object.entries(getterDefs)) {
|
|
12
|
+
Object.defineProperty(getters, name, {
|
|
13
|
+
get: () => fn(state),
|
|
14
|
+
enumerable: true
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
const mutations = {};
|
|
18
|
+
for (const [name, fn] of Object.entries(mutationDefs)) {
|
|
19
|
+
mutations[name] = (payload) => {
|
|
20
|
+
fn(state, payload);
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const actions = {};
|
|
24
|
+
for (const [name, fn] of Object.entries(actionDefs)) {
|
|
25
|
+
actions[name] = async (payload) => {
|
|
26
|
+
const context = {
|
|
27
|
+
state,
|
|
28
|
+
getters,
|
|
29
|
+
commit: (mutation, nextPayload) => mutations[mutation]?.(nextPayload),
|
|
30
|
+
dispatch: (action, nextPayload) => actions[action]?.(nextPayload)
|
|
31
|
+
};
|
|
32
|
+
return await fn(context, payload);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
state,
|
|
37
|
+
getters,
|
|
38
|
+
mutations,
|
|
39
|
+
actions,
|
|
40
|
+
commit(name, payload) {
|
|
41
|
+
if (mutations[name]) {
|
|
42
|
+
mutations[name](payload);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
console.warn(`[TestUtils] Mutation '${name}' not found`);
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
async dispatch(name, payload) {
|
|
49
|
+
if (actions[name]) {
|
|
50
|
+
return await actions[name](payload);
|
|
51
|
+
}
|
|
52
|
+
console.warn(`[TestUtils] Action '${name}' not found`);
|
|
53
|
+
return undefined;
|
|
54
|
+
},
|
|
55
|
+
reset() {
|
|
56
|
+
Object.keys(state).forEach((key) => delete state[key]);
|
|
57
|
+
Object.assign(state, initialState);
|
|
58
|
+
},
|
|
59
|
+
setState(newState) {
|
|
60
|
+
Object.assign(state, newState);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function mockRouter(config = {}) {
|
|
65
|
+
const { initialRoute = '/', routes = [] } = config;
|
|
66
|
+
const currentRoute = reactive({
|
|
67
|
+
path: initialRoute,
|
|
68
|
+
name: null,
|
|
69
|
+
params: {},
|
|
70
|
+
query: {},
|
|
71
|
+
hash: ''
|
|
72
|
+
});
|
|
73
|
+
const beforeEachGuards = [];
|
|
74
|
+
const afterEachHooks = [];
|
|
75
|
+
function parseUrl(url) {
|
|
76
|
+
const [pathAndQuery, hash = ''] = url.split('#');
|
|
77
|
+
const [path = '', queryString = ''] = pathAndQuery.split('?');
|
|
78
|
+
const query = {};
|
|
79
|
+
if (queryString) {
|
|
80
|
+
queryString.split('&').forEach((param) => {
|
|
81
|
+
const [key, value] = param.split('=');
|
|
82
|
+
query[decodeURIComponent(key)] = decodeURIComponent(value || '');
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
let matchedRoute = null;
|
|
86
|
+
let params = {};
|
|
87
|
+
for (const route of routes) {
|
|
88
|
+
const match = matchPath(path, route.path);
|
|
89
|
+
if (match) {
|
|
90
|
+
matchedRoute = route;
|
|
91
|
+
params = match.params;
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
path,
|
|
97
|
+
name: matchedRoute?.name || null,
|
|
98
|
+
params,
|
|
99
|
+
query,
|
|
100
|
+
hash
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
function matchPath(path, pattern) {
|
|
104
|
+
const paramNames = [];
|
|
105
|
+
const regexPattern = pattern
|
|
106
|
+
.replace(/\*/g, '.*')
|
|
107
|
+
.replace(/:([^/]+)/g, (_match, name) => {
|
|
108
|
+
paramNames.push(name);
|
|
109
|
+
return '([^/]+)';
|
|
110
|
+
});
|
|
111
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
112
|
+
const match = path.match(regex);
|
|
113
|
+
if (!match)
|
|
114
|
+
return null;
|
|
115
|
+
const params = {};
|
|
116
|
+
paramNames.forEach((name, index) => {
|
|
117
|
+
params[name] = match[index + 1];
|
|
118
|
+
});
|
|
119
|
+
return { params };
|
|
120
|
+
}
|
|
121
|
+
Object.assign(currentRoute, parseUrl(initialRoute));
|
|
122
|
+
return {
|
|
123
|
+
currentRoute,
|
|
124
|
+
async push(path) {
|
|
125
|
+
const to = parseUrl(path);
|
|
126
|
+
const from = { ...currentRoute };
|
|
127
|
+
for (const guard of beforeEachGuards) {
|
|
128
|
+
const result = await guard(to, from, () => { });
|
|
129
|
+
if (result === false)
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
Object.assign(currentRoute, to);
|
|
133
|
+
for (const hook of afterEachHooks) {
|
|
134
|
+
await hook(to, from);
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
async replace(path) {
|
|
138
|
+
await this.push(path);
|
|
139
|
+
},
|
|
140
|
+
back() {
|
|
141
|
+
// Mock - does nothing in test environment
|
|
142
|
+
},
|
|
143
|
+
beforeEach(guard) {
|
|
144
|
+
beforeEachGuards.push(guard);
|
|
145
|
+
return () => {
|
|
146
|
+
const index = beforeEachGuards.indexOf(guard);
|
|
147
|
+
if (index > -1)
|
|
148
|
+
beforeEachGuards.splice(index, 1);
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
afterEach(hook) {
|
|
152
|
+
afterEachHooks.push(hook);
|
|
153
|
+
return () => {
|
|
154
|
+
const index = afterEachHooks.indexOf(hook);
|
|
155
|
+
if (index > -1)
|
|
156
|
+
afterEachHooks.splice(index, 1);
|
|
157
|
+
};
|
|
158
|
+
},
|
|
159
|
+
resolve(name, params = {}) {
|
|
160
|
+
const route = routes.find((candidate) => candidate.name === name);
|
|
161
|
+
if (!route)
|
|
162
|
+
return '/';
|
|
163
|
+
let path = route.path;
|
|
164
|
+
for (const [key, value] of Object.entries(params)) {
|
|
165
|
+
path = path.replace(`:${key}`, String(value));
|
|
166
|
+
}
|
|
167
|
+
return path;
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
export async function mountComponent(ComponentClass, options = {}) {
|
|
172
|
+
const { props = {}, store = null, router = null, target = document.createElement('div') } = options;
|
|
173
|
+
const env = {};
|
|
174
|
+
if (store)
|
|
175
|
+
env.store = store;
|
|
176
|
+
if (router)
|
|
177
|
+
env.router = router;
|
|
178
|
+
return await mount(ComponentClass, target, {
|
|
179
|
+
props,
|
|
180
|
+
env
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
export function wait(ms) {
|
|
184
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
185
|
+
}
|
|
186
|
+
export function nextTick() {
|
|
187
|
+
return new Promise((resolve) => {
|
|
188
|
+
requestAnimationFrame(() => resolve());
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
export async function flushPromises() {
|
|
192
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
193
|
+
}
|
|
194
|
+
export const userEvent = {
|
|
195
|
+
async click(element) {
|
|
196
|
+
element.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
197
|
+
await flushPromises();
|
|
198
|
+
},
|
|
199
|
+
async type(input, text) {
|
|
200
|
+
input.value = text;
|
|
201
|
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
202
|
+
await flushPromises();
|
|
203
|
+
},
|
|
204
|
+
async submit(form) {
|
|
205
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
|
206
|
+
await flushPromises();
|
|
207
|
+
},
|
|
208
|
+
async select(select, value) {
|
|
209
|
+
select.value = value;
|
|
210
|
+
select.dispatchEvent(new Event('change', { bubbles: true }));
|
|
211
|
+
await flushPromises();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
export const dom = {
|
|
215
|
+
query(selector, container = document) {
|
|
216
|
+
return container.querySelector(selector);
|
|
217
|
+
},
|
|
218
|
+
queryAll(selector, container = document) {
|
|
219
|
+
return container.querySelectorAll(selector);
|
|
220
|
+
},
|
|
221
|
+
hasClass(element, className) {
|
|
222
|
+
return element?.classList?.contains(className) || false;
|
|
223
|
+
},
|
|
224
|
+
text(element) {
|
|
225
|
+
return element?.textContent?.trim() || '';
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
export const TestUtils = {
|
|
229
|
+
createMockStore,
|
|
230
|
+
mockRouter,
|
|
231
|
+
mountComponent,
|
|
232
|
+
wait,
|
|
233
|
+
nextTick,
|
|
234
|
+
flushPromises,
|
|
235
|
+
userEvent,
|
|
236
|
+
dom
|
|
237
|
+
};
|
|
238
|
+
export default TestUtils;
|