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,198 @@
|
|
|
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
|
+
const keys = Object.keys(this._state);
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
;
|
|
111
|
+
this._state[key] = undefined;
|
|
112
|
+
}
|
|
113
|
+
Object.assign(this._state, initialState);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
static define(id, config) {
|
|
117
|
+
return function useStore() {
|
|
118
|
+
if (!stores.has(id)) {
|
|
119
|
+
stores.set(id, new Store(id, config));
|
|
120
|
+
}
|
|
121
|
+
return stores.get(id);
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
static get(id) {
|
|
125
|
+
return stores.get(id);
|
|
126
|
+
}
|
|
127
|
+
static has(id) {
|
|
128
|
+
return stores.has(id);
|
|
129
|
+
}
|
|
130
|
+
static remove(id) {
|
|
131
|
+
const store = stores.get(id);
|
|
132
|
+
if (store) {
|
|
133
|
+
store.reset();
|
|
134
|
+
return stores.delete(id);
|
|
135
|
+
}
|
|
136
|
+
return false;
|
|
137
|
+
}
|
|
138
|
+
static clear() {
|
|
139
|
+
stores.clear();
|
|
140
|
+
}
|
|
141
|
+
static storeIds() {
|
|
142
|
+
return Array.from(stores.keys());
|
|
143
|
+
}
|
|
144
|
+
static use(plugin) {
|
|
145
|
+
plugins.push(plugin);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
export function createPersistencePlugin(options = {}) {
|
|
149
|
+
const { storage = localStorage, key, paths } = options;
|
|
150
|
+
return function persistencePlugin(store) {
|
|
151
|
+
const storageKey = key || `metaowl:store:${store.id}`;
|
|
152
|
+
try {
|
|
153
|
+
const saved = storage.getItem(storageKey);
|
|
154
|
+
if (saved) {
|
|
155
|
+
const persisted = JSON.parse(saved);
|
|
156
|
+
if (paths) {
|
|
157
|
+
for (const path of paths) {
|
|
158
|
+
if (path in persisted && path in store.state) {
|
|
159
|
+
store.state[path] = persisted[path];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
Object.assign(store.state, persisted);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
console.warn('[metaowl] Failed to restore store from storage:', error);
|
|
170
|
+
}
|
|
171
|
+
store.subscribe((_mutation, state) => {
|
|
172
|
+
try {
|
|
173
|
+
const toPersist = paths
|
|
174
|
+
? Object.fromEntries(paths.map((path) => [path, state[path]]))
|
|
175
|
+
: state;
|
|
176
|
+
storage.setItem(storageKey, JSON.stringify(toPersist));
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
console.warn('[metaowl] Failed to persist store:', error);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
export function createStore(initialState = {}) {
|
|
185
|
+
const state = reactive({ ...initialState });
|
|
186
|
+
state.$patch = (partialState) => {
|
|
187
|
+
Object.assign(state, partialState);
|
|
188
|
+
};
|
|
189
|
+
state.$reset = () => {
|
|
190
|
+
Object.keys(state).forEach((key) => {
|
|
191
|
+
if (!key.startsWith('$')) {
|
|
192
|
+
delete state[key];
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
Object.assign(state, initialState);
|
|
196
|
+
};
|
|
197
|
+
return state;
|
|
198
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
7
|
+
import { globSync } from 'glob';
|
|
8
|
+
import tsconfigPaths from 'vite-tsconfig-paths';
|
|
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
|
+
}
|
|
22
|
+
function resolveOwlPath() {
|
|
23
|
+
return require.resolve('@odoo/owl/dist/owl.es.js', {
|
|
24
|
+
paths: [process.cwd(), dirname(fileURLToPath(import.meta.url))]
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function collectXml(globPattern) {
|
|
28
|
+
return globSync(globPattern).map((filePath) => filePath.replace(/^src[\\/]/, '/'));
|
|
29
|
+
}
|
|
30
|
+
function mergeXmlFiles(xmlPaths) {
|
|
31
|
+
const templates = xmlPaths.map((filePath) => {
|
|
32
|
+
try {
|
|
33
|
+
let content = readFileSync(filePath, 'utf-8');
|
|
34
|
+
content = content.replace(/<templates>/g, '').replace(/<\/templates>/g, '');
|
|
35
|
+
return content;
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
console.error(`[metaowl] Failed to read XML file: ${filePath}`, error);
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
}).join('');
|
|
42
|
+
return '<templates>' + templates + '</templates>';
|
|
43
|
+
}
|
|
44
|
+
export async function metaowlPlugin(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;
|
|
48
|
+
const componentXml = collectXml(`${componentsDir}/**/*.xml`);
|
|
49
|
+
const pageXml = collectXml(`${pagesDir}/**/*.xml`);
|
|
50
|
+
const layoutXml = collectXml(`${layoutsDir}/**/*.xml`);
|
|
51
|
+
const allComponents = [...layoutXml, ...pageXml, ...componentXml];
|
|
52
|
+
let outDirResolved = null;
|
|
53
|
+
const autoImportDtsPath = resolve(process.cwd(), '.metaowl', 'components.d.ts');
|
|
54
|
+
let autoImportPlugin = null;
|
|
55
|
+
if (autoImport.enabled) {
|
|
56
|
+
const { generateComponentDts, scanComponents } = await import('../modules/auto-import.js');
|
|
57
|
+
const components = await scanComponents(componentsDir, { pattern: autoImport.pattern || '*.js' });
|
|
58
|
+
const metaowlDir = dirname(autoImportDtsPath);
|
|
59
|
+
if (!existsSync(metaowlDir)) {
|
|
60
|
+
mkdirSync(metaowlDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
await generateComponentDts(components, autoImportDtsPath);
|
|
63
|
+
autoImportPlugin = {
|
|
64
|
+
name: 'metaowl:auto-import',
|
|
65
|
+
enforce: 'pre',
|
|
66
|
+
configResolved() {
|
|
67
|
+
// Components are scanned at startup.
|
|
68
|
+
},
|
|
69
|
+
handleHotUpdate({ file }) {
|
|
70
|
+
if (file.startsWith(resolve(componentsDir)) && file.endsWith('.js')) {
|
|
71
|
+
void scanComponents(componentsDir, { pattern: autoImport.pattern || '*.js' }).then((nextComponents) => {
|
|
72
|
+
return generateComponentDts(nextComponents, autoImportDtsPath);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const plugins = [
|
|
79
|
+
...(autoImportPlugin ? [autoImportPlugin] : []),
|
|
80
|
+
tsconfigPaths({ root: process.cwd() }),
|
|
81
|
+
{
|
|
82
|
+
name: 'metaowl:define',
|
|
83
|
+
config(cfg, { mode }) {
|
|
84
|
+
dotenvConfig();
|
|
85
|
+
const isDev = mode === 'development';
|
|
86
|
+
const safeEnv = Object.fromEntries(Object.entries(process.env).filter(([key]) => key === 'NODE_ENV' || (envPrefix && key.startsWith(envPrefix))));
|
|
87
|
+
cfg.define = {
|
|
88
|
+
...(cfg.define ?? {}),
|
|
89
|
+
DEV_MODE: isDev,
|
|
90
|
+
COMPONENTS: JSON.stringify(isDev ? allComponents : ['/templates.xml']),
|
|
91
|
+
'process.env': safeEnv
|
|
92
|
+
};
|
|
93
|
+
cfg.root = cfg.root ?? rootDir;
|
|
94
|
+
cfg.publicDir = cfg.publicDir ?? publicDir;
|
|
95
|
+
cfg.appType = cfg.appType ?? 'spa';
|
|
96
|
+
const owlPath = resolveOwlPath();
|
|
97
|
+
cfg.resolve = {
|
|
98
|
+
...(cfg.resolve ?? {}),
|
|
99
|
+
alias: {
|
|
100
|
+
...(cfg.resolve?.alias ?? {}),
|
|
101
|
+
'@odoo/owl': owlPath
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
cfg.build = {
|
|
105
|
+
outDir,
|
|
106
|
+
emptyOutDir: true,
|
|
107
|
+
sourcemap: isDev,
|
|
108
|
+
chunkSizeWarningLimit: 1024,
|
|
109
|
+
target: 'esnext',
|
|
110
|
+
rollupOptions: {
|
|
111
|
+
input: resolve(rootDir, 'index.html'),
|
|
112
|
+
output: {
|
|
113
|
+
manualChunks: {
|
|
114
|
+
vendor: vendorPackages,
|
|
115
|
+
framework: [frameworkEntry]
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
...(cfg.build ?? {})
|
|
120
|
+
};
|
|
121
|
+
cfg.optimizeDeps = {
|
|
122
|
+
include: ['@odoo/owl'],
|
|
123
|
+
...(cfg.optimizeDeps ?? {})
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
configResolved(resolvedConfig) {
|
|
127
|
+
outDirResolved = resolve(resolvedConfig.root, resolvedConfig.build.outDir);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
name: 'metaowl:app',
|
|
132
|
+
transform(code, id) {
|
|
133
|
+
if (!id.endsWith('/metaowl.js'))
|
|
134
|
+
return null;
|
|
135
|
+
const pagesRel = pagesDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
|
|
136
|
+
const layoutsRel = layoutsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
|
|
137
|
+
return {
|
|
138
|
+
code: code.replace(/boot\(\s*\)/, `boot(import.meta.glob('./${pagesRel}/**/*.js', { eager: true }), import.meta.glob('./${layoutsRel}/**/*.js', { eager: true }))`),
|
|
139
|
+
map: null
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: 'metaowl:styles',
|
|
145
|
+
transform(code, id) {
|
|
146
|
+
if (!id.endsWith('/css.js'))
|
|
147
|
+
return null;
|
|
148
|
+
const compRel = componentsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
|
|
149
|
+
const pagesRel = pagesDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
|
|
150
|
+
const layoutsRel = layoutsDir.replace(new RegExp(`^${rootDir}[\\/]`), '');
|
|
151
|
+
return {
|
|
152
|
+
code: code + '\n' +
|
|
153
|
+
`import.meta.glob('/${compRel}/**/*.{css,scss}', { eager: true })\n` +
|
|
154
|
+
`import.meta.glob('/${pagesRel}/**/*.{css,scss}', { eager: true })\n` +
|
|
155
|
+
`import.meta.glob('/${layoutsRel}/**/*.{css,scss}', { eager: true })\n`,
|
|
156
|
+
map: null
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'metaowl:copy-assets',
|
|
162
|
+
apply: 'build',
|
|
163
|
+
closeBundle() {
|
|
164
|
+
if (!outDirResolved)
|
|
165
|
+
return;
|
|
166
|
+
const projectRoot = process.cwd();
|
|
167
|
+
const xmlFiles = globSync([`${componentsDir}/**/*.xml`, `${pagesDir}/**/*.xml`, `${layoutsDir}/**/*.xml`]);
|
|
168
|
+
const mergedXml = mergeXmlFiles(xmlFiles);
|
|
169
|
+
const hash = createHash('sha256').update(mergedXml).digest('hex').slice(0, 8);
|
|
170
|
+
const hashedFilename = `templates.${hash}.xml`;
|
|
171
|
+
writeFileSync(resolve(outDirResolved, hashedFilename), mergedXml, 'utf-8');
|
|
172
|
+
const outputFiles = globSync(['**/*.html', '**/*.js'], { cwd: outDirResolved, absolute: true });
|
|
173
|
+
for (const file of outputFiles) {
|
|
174
|
+
const content = readFileSync(file, 'utf-8');
|
|
175
|
+
if (content.includes('/templates.xml')) {
|
|
176
|
+
writeFileSync(file, content.replace(/\/templates\.xml/g, `/${hashedFilename}`), 'utf-8');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const srcImages = resolve(projectRoot, rootDir, 'assets', 'images');
|
|
180
|
+
if (existsSync(srcImages)) {
|
|
181
|
+
cpSync(srcImages, resolve(outDirResolved, 'assets', 'images'), { recursive: true });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
];
|
|
186
|
+
return plugins;
|
|
187
|
+
}
|
|
188
|
+
export async function metaowlConfig(options = {}) {
|
|
189
|
+
const { server, preview, build, ...metaowlOptions } = options;
|
|
190
|
+
const plugins = await metaowlPlugin(metaowlOptions);
|
|
191
|
+
return {
|
|
192
|
+
server: { port: 3000, strictPort: true, host: true, ...server },
|
|
193
|
+
preview: { port: 4173, strictPort: true, ...preview },
|
|
194
|
+
...(build ? { build } : {}),
|
|
195
|
+
plugins
|
|
196
|
+
};
|
|
197
|
+
}
|