metaowl 0.1.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/CONTRIBUTING.md +49 -0
- package/LICENSE +147 -0
- package/README.md +543 -0
- package/bin/metaowl-build.js +12 -0
- package/bin/metaowl-create.js +270 -0
- package/bin/metaowl-dev.js +12 -0
- package/bin/metaowl-generate.js +176 -0
- package/bin/metaowl-lint.js +71 -0
- package/bin/utils.js +61 -0
- package/config/jsconfig.base.json +3 -0
- package/config/tsconfig.base.json +18 -0
- package/eslint.js +49 -0
- package/index.js +32 -0
- package/modules/app-mounter.js +40 -0
- package/modules/cache.js +57 -0
- package/modules/fetch.js +44 -0
- package/modules/file-router.js +60 -0
- package/modules/meta.js +119 -0
- package/modules/router.js +62 -0
- package/modules/templates-manager.js +20 -0
- package/package.json +68 -0
- package/postcss.cjs +43 -0
- package/test/cache.test.js +55 -0
- package/test/fetch.test.js +100 -0
- package/test/file-router.test.js +55 -0
- package/test/meta.test.js +146 -0
- package/test/router.test.js +77 -0
- package/test/templates-manager.test.js +62 -0
- package/vite/plugin.js +216 -0
- package/vitest.config.js +8 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { mount } from '@odoo/owl'
|
|
2
|
+
import { mergeTemplates } from './templates-manager.js'
|
|
3
|
+
|
|
4
|
+
const _defaults = {
|
|
5
|
+
warnIfNoStaticProps: true,
|
|
6
|
+
willStartTimeout: 10000,
|
|
7
|
+
translatableAttributes: ['title', 'placeholder', 'label', 'alt']
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let _config = { ..._defaults }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Override or extend the default OWL mount configuration.
|
|
14
|
+
* Call before boot() in your project's metaowl.js.
|
|
15
|
+
*
|
|
16
|
+
* @param {object} config - Partial OWL config merged over the defaults.
|
|
17
|
+
*/
|
|
18
|
+
export function configureOwl(config) {
|
|
19
|
+
_config = { ..._defaults, ...config }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Mount the resolved route's OWL component into `#app`.
|
|
24
|
+
*
|
|
25
|
+
* Loads and merges all XML templates (collected at build time by the
|
|
26
|
+
* metaowl Vite plugin via the `COMPONENTS` define), then mounts the component
|
|
27
|
+
* using the active OWL config.
|
|
28
|
+
*
|
|
29
|
+
* @param {object[]} route - Single-element array returned by `processRoutes()`.
|
|
30
|
+
* @returns {Promise<void>}
|
|
31
|
+
*/
|
|
32
|
+
export async function mountApp(route) {
|
|
33
|
+
// COMPONENTS is a string[] injected at build time by the metaowl Vite plugin
|
|
34
|
+
const components = typeof COMPONENTS !== 'undefined' ? COMPONENTS : []
|
|
35
|
+
const templates = await mergeTemplates(components)
|
|
36
|
+
const mountElement = document.getElementById('metaowl')
|
|
37
|
+
mountElement.innerHTML = ''
|
|
38
|
+
|
|
39
|
+
await mount(route[0].component, mountElement, { ..._config, templates })
|
|
40
|
+
}
|
package/modules/cache.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Async-style localStorage wrapper.
|
|
3
|
+
*
|
|
4
|
+
* Values are automatically JSON-serialised on write and deserialised on read.
|
|
5
|
+
* All methods return Promises so they are interchangeable with IndexedDB-based
|
|
6
|
+
* alternatives without changing call-sites.
|
|
7
|
+
*/
|
|
8
|
+
export default class Cache {
|
|
9
|
+
/**
|
|
10
|
+
* Retrieve a value by key.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} key
|
|
13
|
+
* @returns {Promise<any>} Parsed value, or `null` if the key does not exist.
|
|
14
|
+
*/
|
|
15
|
+
static async get(key) {
|
|
16
|
+
return JSON.parse(localStorage.getItem(key))
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Store a value under the given key.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} key
|
|
23
|
+
* @param {any} value - Must be JSON-serialisable.
|
|
24
|
+
* @returns {Promise<void>}
|
|
25
|
+
*/
|
|
26
|
+
static async set(key, value) {
|
|
27
|
+
localStorage.setItem(key, JSON.stringify(value))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Remove a single entry.
|
|
32
|
+
*
|
|
33
|
+
* @param {string} key
|
|
34
|
+
* @returns {Promise<void>}
|
|
35
|
+
*/
|
|
36
|
+
static async remove(key) {
|
|
37
|
+
localStorage.removeItem(key)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove **all** entries from localStorage.
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<void>}
|
|
44
|
+
*/
|
|
45
|
+
static async clear() {
|
|
46
|
+
localStorage.clear()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Return all keys currently stored in localStorage.
|
|
51
|
+
*
|
|
52
|
+
* @returns {Promise<string[]>}
|
|
53
|
+
*/
|
|
54
|
+
static async keys() {
|
|
55
|
+
return Array.from({ length: localStorage.length }, (_, i) => localStorage.key(i))
|
|
56
|
+
}
|
|
57
|
+
}
|
package/modules/fetch.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export default class Fetch {
|
|
2
|
+
static _baseUrl = ''
|
|
3
|
+
static _onError = null
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Configure the Fetch helper. Call once in your metaowl.js before boot().
|
|
7
|
+
*
|
|
8
|
+
* @param {object} options
|
|
9
|
+
* @param {string} [options.baseUrl=''] - Base URL prepended to every internal request.
|
|
10
|
+
* @param {function} [options.onError] - Callback invoked on network errors.
|
|
11
|
+
*/
|
|
12
|
+
static configure({ baseUrl = '', onError = null } = {}) {
|
|
13
|
+
Fetch._baseUrl = baseUrl
|
|
14
|
+
Fetch._onError = onError
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Perform a fetch request.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} url - Path or full URL.
|
|
21
|
+
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} [method='GET']
|
|
22
|
+
* @param {object|null} [data=null] - Request body (JSON-serialised).
|
|
23
|
+
* @param {boolean} [internal=true] - Prepend baseUrl when true.
|
|
24
|
+
* @param {boolean} [triggerErrorHandler=true] - Call onError callback on failure.
|
|
25
|
+
* @returns {Promise<any|null>}
|
|
26
|
+
*/
|
|
27
|
+
static async url(url, method = 'GET', data = null, internal = true, triggerErrorHandler = true) {
|
|
28
|
+
const fullUrl = `${internal ? Fetch._baseUrl : ''}${url}`
|
|
29
|
+
|
|
30
|
+
const response = await fetch(fullUrl, {
|
|
31
|
+
method,
|
|
32
|
+
body: data ? JSON.stringify(data) : null
|
|
33
|
+
}).catch(error => {
|
|
34
|
+
console.warn('[metaowl] Fetch error:', error)
|
|
35
|
+
if (triggerErrorHandler && Fetch._onError) {
|
|
36
|
+
Fetch._onError(error)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
if (!response) return null
|
|
41
|
+
|
|
42
|
+
return response.json()
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derives a URL path from an import.meta.glob key.
|
|
3
|
+
*
|
|
4
|
+
* Convention (mirrors Nuxt/Next.js file-based routing):
|
|
5
|
+
* './pages/index/Index.js' → '/'
|
|
6
|
+
* './pages/about/About.js' → '/about'
|
|
7
|
+
* './pages/about/bla/Bla.js' → '/about/bla'
|
|
8
|
+
*
|
|
9
|
+
* Rule: the *directory* path relative to pages/ becomes the URL.
|
|
10
|
+
* A top-level directory named 'index' maps to '/'.
|
|
11
|
+
*
|
|
12
|
+
* @param {string} key - import.meta.glob key, e.g. './pages/about/About.js'
|
|
13
|
+
* @returns {string} URL path
|
|
14
|
+
*/
|
|
15
|
+
function pathFromKey(key) {
|
|
16
|
+
// Strip leading './' and 'pages/' prefix
|
|
17
|
+
const rel = key.replace(/^\.\/pages\//, '')
|
|
18
|
+
// Drop the filename, keep directory segments
|
|
19
|
+
const dirParts = rel.split('/').slice(0, -1)
|
|
20
|
+
if (dirParts.length === 1 && dirParts[0] === 'index') return '/'
|
|
21
|
+
return '/' + dirParts.join('/')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extracts the page component from an eagerly-imported module.
|
|
26
|
+
* Prefers default export, falls back to the first function export.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} mod - Eagerly imported module
|
|
29
|
+
* @param {string} key - Glob key (for error messages)
|
|
30
|
+
* @returns {Function}
|
|
31
|
+
*/
|
|
32
|
+
function componentFromModule(mod, key) {
|
|
33
|
+
if (typeof mod.default === 'function') return mod.default
|
|
34
|
+
const named = Object.values(mod).find(v => typeof v === 'function')
|
|
35
|
+
if (!named) throw new Error(`[metaowl] No component export found in "${key}"`)
|
|
36
|
+
return named
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Builds a metaowl route table from an import.meta.glob result.
|
|
41
|
+
*
|
|
42
|
+
* Usage in src/metaowl.js:
|
|
43
|
+
*
|
|
44
|
+
* import { boot } from 'metaowl'
|
|
45
|
+
* boot(import.meta.glob('./pages/**\/*.js', { eager: true }))
|
|
46
|
+
*
|
|
47
|
+
* @param {Record<string, object>} modules - Result of import.meta.glob({ eager: true })
|
|
48
|
+
* @returns {object[]} Route table for processRoutes()
|
|
49
|
+
*/
|
|
50
|
+
export function buildRoutes(modules) {
|
|
51
|
+
return Object.entries(modules).map(([key, mod]) => {
|
|
52
|
+
const path = pathFromKey(key)
|
|
53
|
+
const name = path === '/' ? 'index' : path.slice(1).replace(/\//g, '-')
|
|
54
|
+
return {
|
|
55
|
+
name,
|
|
56
|
+
path: [path],
|
|
57
|
+
component: componentFromModule(mod, key)
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
}
|
package/modules/meta.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module Meta
|
|
3
|
+
*
|
|
4
|
+
* Programmatic helpers for managing document meta tags at runtime.
|
|
5
|
+
*
|
|
6
|
+
* Each function is idempotent: the relevant `<meta>` or `<link>` element is
|
|
7
|
+
* created on first call if it does not already exist, then its content is
|
|
8
|
+
* updated on every subsequent call as well.
|
|
9
|
+
*
|
|
10
|
+
* Import the entire namespace via:
|
|
11
|
+
* import { Meta } from 'metaowl'
|
|
12
|
+
* Meta.title('My Page')
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function _nameMeta(name, value) {
|
|
16
|
+
if (!value) return
|
|
17
|
+
let el = document.querySelector(`meta[name="${name}"]`)
|
|
18
|
+
if (!el) {
|
|
19
|
+
el = document.createElement('meta')
|
|
20
|
+
el.name = name
|
|
21
|
+
document.head.appendChild(el)
|
|
22
|
+
}
|
|
23
|
+
el.content = value
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function _propMeta(property, value) {
|
|
27
|
+
if (!value) return
|
|
28
|
+
let el = document.querySelector(`meta[property="${property}"]`)
|
|
29
|
+
if (!el) {
|
|
30
|
+
el = document.createElement('meta')
|
|
31
|
+
el.setAttribute('property', property)
|
|
32
|
+
document.head.appendChild(el)
|
|
33
|
+
}
|
|
34
|
+
el.content = value
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Set the document `<title>`. @param {string} title */
|
|
38
|
+
export function title(title) {
|
|
39
|
+
if (!title) return
|
|
40
|
+
document.title = title
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {string} description */
|
|
44
|
+
export function description(description) { _nameMeta('description', description) }
|
|
45
|
+
|
|
46
|
+
/** @param {string} keywords */
|
|
47
|
+
export function keywords(keywords) { _nameMeta('keywords', keywords) }
|
|
48
|
+
|
|
49
|
+
/** @param {string} author */
|
|
50
|
+
export function author(author) { _nameMeta('author', author) }
|
|
51
|
+
|
|
52
|
+
/** @param {string} url */
|
|
53
|
+
export function canonical(url) {
|
|
54
|
+
if (!url) return
|
|
55
|
+
let el = document.querySelector('link[rel="canonical"]')
|
|
56
|
+
if (!el) {
|
|
57
|
+
el = document.createElement('link')
|
|
58
|
+
el.rel = 'canonical'
|
|
59
|
+
document.head.appendChild(el)
|
|
60
|
+
}
|
|
61
|
+
el.href = url
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {string} title */
|
|
65
|
+
export function ogTitle(title) { _propMeta('og:title', title) }
|
|
66
|
+
|
|
67
|
+
/** @param {string} description */
|
|
68
|
+
export function ogDescription(description) { _propMeta('og:description', description) }
|
|
69
|
+
|
|
70
|
+
/** @param {string} image */
|
|
71
|
+
export function ogImage(image) { _propMeta('og:image', image) }
|
|
72
|
+
|
|
73
|
+
/** @param {string} url */
|
|
74
|
+
export function ogUrl(url) { _propMeta('og:url', url) }
|
|
75
|
+
|
|
76
|
+
/** @param {string} type */
|
|
77
|
+
export function ogType(type) { _propMeta('og:type', type) }
|
|
78
|
+
|
|
79
|
+
/** @param {string} siteName */
|
|
80
|
+
export function ogSiteName(siteName) { _propMeta('og:site_name', siteName) }
|
|
81
|
+
|
|
82
|
+
/** @param {string} locale */
|
|
83
|
+
export function ogLocale(locale) { _propMeta('og:locale', locale) }
|
|
84
|
+
|
|
85
|
+
/** @param {string|number} width */
|
|
86
|
+
export function ogImageWidth(width) { _propMeta('og:image:width', width) }
|
|
87
|
+
|
|
88
|
+
/** @param {string|number} height */
|
|
89
|
+
export function ogImageHeight(height) { _propMeta('og:image:height', height) }
|
|
90
|
+
|
|
91
|
+
/** @param {string} card */
|
|
92
|
+
export function twitterCard(card) { _nameMeta('twitter:card', card) }
|
|
93
|
+
|
|
94
|
+
/** @param {string} site */
|
|
95
|
+
export function twitterSite(site) { _nameMeta('twitter:site', site) }
|
|
96
|
+
|
|
97
|
+
/** @param {string} creator */
|
|
98
|
+
export function twitterCreator(creator) { _nameMeta('twitter:creator', creator) }
|
|
99
|
+
|
|
100
|
+
/** @param {string} title */
|
|
101
|
+
export function twitterTitle(title) { _nameMeta('twitter:title', title) }
|
|
102
|
+
|
|
103
|
+
/** @param {string} description */
|
|
104
|
+
export function twitterDescription(description) { _nameMeta('twitter:description', description) }
|
|
105
|
+
|
|
106
|
+
/** @param {string} image */
|
|
107
|
+
export function twitterImage(image) { _nameMeta('twitter:image', image) }
|
|
108
|
+
|
|
109
|
+
/** @param {string} alt */
|
|
110
|
+
export function twitterImageAlt(alt) { _nameMeta('twitter:image:alt', alt) }
|
|
111
|
+
|
|
112
|
+
/** @param {string} url */
|
|
113
|
+
export function twitterUrl(url) { _nameMeta('twitter:url', url) }
|
|
114
|
+
|
|
115
|
+
/** @param {string} siteId */
|
|
116
|
+
export function twitterSiteId(siteId) { _nameMeta('twitter:site:id', siteId) }
|
|
117
|
+
|
|
118
|
+
/** @param {string} creatorId */
|
|
119
|
+
export function twitterCreatorId(creatorId) { _nameMeta('twitter:creator:id', creatorId) }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolves the current URL against a route table.
|
|
3
|
+
* Used internally by processRoutes().
|
|
4
|
+
*/
|
|
5
|
+
class Router {
|
|
6
|
+
constructor(routes) {
|
|
7
|
+
this.routes = routes
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
resolve() {
|
|
11
|
+
const match = this.routes.filter(route =>
|
|
12
|
+
(typeof route.path === 'string' && document.location.pathname === route.path) ||
|
|
13
|
+
(Array.isArray(route.path) && route.path.includes(document.location.pathname))
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
if (match.length !== 1) {
|
|
17
|
+
throw new Error(`No route found for "${document.location.pathname}".`)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return match
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Injects SSG-compatible path variants for a route:
|
|
26
|
+
* trailing slash, .html suffix, /index.html suffix.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} route
|
|
29
|
+
* @param {string} path
|
|
30
|
+
* @returns {object}
|
|
31
|
+
*/
|
|
32
|
+
function injectSystemRoutes(route, path) {
|
|
33
|
+
if (path === '/') {
|
|
34
|
+
if (!route.path.includes('/index.html')) route.path.push('/index.html')
|
|
35
|
+
} else {
|
|
36
|
+
if (!route.path.includes(`${path}.html`)) route.path.push(`${path}.html`)
|
|
37
|
+
if (!route.path.includes(`${path}/`)) route.path.push(`${path}/`)
|
|
38
|
+
if (!route.path.includes(`${path}/index.html`)) route.path.push(`${path}/index.html`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return route
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Expands all routes with SSG path variants, then resolves the current URL.
|
|
46
|
+
*
|
|
47
|
+
* @param {object[]} routes
|
|
48
|
+
* @returns {Promise<object[]>}
|
|
49
|
+
*/
|
|
50
|
+
export async function processRoutes(routes) {
|
|
51
|
+
for (const route of routes) {
|
|
52
|
+
const originalPaths = [...route.path]
|
|
53
|
+
for (const path of originalPaths) {
|
|
54
|
+
if (typeof path === 'string') {
|
|
55
|
+
injectSystemRoutes(route, path)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return new Router(routes).resolve()
|
|
61
|
+
}
|
|
62
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { loadFile } from '@odoo/owl'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads and concatenates a list of OWL XML template files into a single
|
|
5
|
+
* `<templates>` string ready to be passed to OWL's mount() options.
|
|
6
|
+
*
|
|
7
|
+
* @param {string[]} files - Array of URL-style XML paths, e.g. ['/owl/components/Header/Header.xml']
|
|
8
|
+
* @returns {Promise<string>}
|
|
9
|
+
*/
|
|
10
|
+
export async function mergeTemplates(files) {
|
|
11
|
+
let templates = '<templates>'
|
|
12
|
+
for (const file of files) {
|
|
13
|
+
try {
|
|
14
|
+
templates += await loadFile(file)
|
|
15
|
+
} catch (e) {
|
|
16
|
+
console.error(`[metaowl] Failed to load template: ${file}`, e)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return templates + '</templates>'
|
|
20
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "metaowl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight meta-framework for Odoo OWL — file-based routing, app mounting, Fetch helper, Cache, Meta tags, SSG generator, and a Vite plugin.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./vite": "./vite/plugin.js",
|
|
10
|
+
"./eslint": "./eslint.js",
|
|
11
|
+
"./postcss": "./postcss.cjs"
|
|
12
|
+
},
|
|
13
|
+
"bin": {
|
|
14
|
+
"metaowl-create": "./bin/metaowl-create.js",
|
|
15
|
+
"metaowl-dev": "./bin/metaowl-dev.js",
|
|
16
|
+
"metaowl-build": "./bin/metaowl-build.js",
|
|
17
|
+
"metaowl-generate": "./bin/metaowl-generate.js",
|
|
18
|
+
"metaowl-lint": "./bin/metaowl-lint.js"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"owl",
|
|
22
|
+
"odoo",
|
|
23
|
+
"framework",
|
|
24
|
+
"vite",
|
|
25
|
+
"meta-framework"
|
|
26
|
+
],
|
|
27
|
+
"author": "Dennis Schott",
|
|
28
|
+
"license": "LGPL-3.0-only",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/dennisschott/metaowl"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/dennisschott/metaowl#readme",
|
|
35
|
+
"bugs": {
|
|
36
|
+
"url": "https://github.com/dennisschott/metaowl/issues"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@eslint/js": "^9.20.1",
|
|
40
|
+
"@fullhuman/postcss-purgecss": "^6.0.0",
|
|
41
|
+
"@odoo/owl": "^2.8.2",
|
|
42
|
+
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
|
43
|
+
"@typescript-eslint/parser": "^8.24.1",
|
|
44
|
+
"dotenv": "^16.4.7",
|
|
45
|
+
"eslint": "^9.20.1",
|
|
46
|
+
"eslint-plugin-import": "^2.31.0",
|
|
47
|
+
"eslint-plugin-n": "^17.15.1",
|
|
48
|
+
"eslint-plugin-promise": "^7.2.1",
|
|
49
|
+
"glob": "^13.0.6",
|
|
50
|
+
"globals": "^13.24.0",
|
|
51
|
+
"prettier": "3.5.1",
|
|
52
|
+
"vite": "^7.3.1",
|
|
53
|
+
"vite-plugin-handlebars": "^2.0.0",
|
|
54
|
+
"vite-plugin-restart": "^2.0.0",
|
|
55
|
+
"vite-tsconfig-paths": "^6.1.1"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18.0.0"
|
|
59
|
+
},
|
|
60
|
+
"scripts": {
|
|
61
|
+
"test": "vitest run",
|
|
62
|
+
"test:watch": "vitest"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"jsdom": "^28.1.0",
|
|
66
|
+
"vitest": "^4.0.18"
|
|
67
|
+
}
|
|
68
|
+
}
|
package/postcss.cjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// CommonJS PostCSS config factory for metaowl projects.
|
|
2
|
+
// Usage in postcss.config.cjs:
|
|
3
|
+
//
|
|
4
|
+
// const { createPostcssConfig } = require('metaowl/postcss')
|
|
5
|
+
// module.exports = createPostcssConfig()
|
|
6
|
+
//
|
|
7
|
+
// Override safelist or add content globs:
|
|
8
|
+
//
|
|
9
|
+
// module.exports = createPostcssConfig({
|
|
10
|
+
// safelist: [/^my-custom-class/],
|
|
11
|
+
// content: ['./templates/**/*.html']
|
|
12
|
+
// })
|
|
13
|
+
|
|
14
|
+
const defaultSafelist = []
|
|
15
|
+
|
|
16
|
+
function createPostcssConfig(options = {}) {
|
|
17
|
+
const {
|
|
18
|
+
safelist = [],
|
|
19
|
+
content = [],
|
|
20
|
+
additionalPlugins = []
|
|
21
|
+
} = options
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
plugins: [
|
|
25
|
+
...process.env.NODE_ENV === 'production'
|
|
26
|
+
? [
|
|
27
|
+
require('@fullhuman/postcss-purgecss')({
|
|
28
|
+
content: [
|
|
29
|
+
'./**/*.xml',
|
|
30
|
+
'./**/*.html',
|
|
31
|
+
'./src/**/*.js',
|
|
32
|
+
...content
|
|
33
|
+
],
|
|
34
|
+
safelist: [...defaultSafelist, ...safelist]
|
|
35
|
+
})
|
|
36
|
+
]
|
|
37
|
+
: [],
|
|
38
|
+
...additionalPlugins
|
|
39
|
+
]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { createPostcssConfig }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest'
|
|
2
|
+
import Cache from '../modules/cache.js'
|
|
3
|
+
|
|
4
|
+
// Minimal localStorage stub
|
|
5
|
+
const store = {}
|
|
6
|
+
const localStorageMock = {
|
|
7
|
+
getItem: (k) => store[k] ?? null,
|
|
8
|
+
setItem: (k, v) => { store[k] = v },
|
|
9
|
+
removeItem: (k) => { delete store[k] },
|
|
10
|
+
clear: () => { Object.keys(store).forEach(k => delete store[k]) },
|
|
11
|
+
get length() { return Object.keys(store).length },
|
|
12
|
+
key: (i) => Object.keys(store)[i] ?? null
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true })
|
|
15
|
+
|
|
16
|
+
beforeEach(() => localStorageMock.clear())
|
|
17
|
+
|
|
18
|
+
describe('Cache', () => {
|
|
19
|
+
it('set and get a string value', async () => {
|
|
20
|
+
await Cache.set('name', 'metaowl')
|
|
21
|
+
expect(await Cache.get('name')).toBe('metaowl')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('set and get an object', async () => {
|
|
25
|
+
await Cache.set('obj', { a: 1, b: [2, 3] })
|
|
26
|
+
expect(await Cache.get('obj')).toEqual({ a: 1, b: [2, 3] })
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('get returns null for missing key', async () => {
|
|
30
|
+
expect(await Cache.get('missing')).toBeNull()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('remove deletes the key', async () => {
|
|
34
|
+
await Cache.set('tmp', 'x')
|
|
35
|
+
await Cache.remove('tmp')
|
|
36
|
+
expect(await Cache.get('tmp')).toBeNull()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('clear removes all keys', async () => {
|
|
40
|
+
await Cache.set('a', 1)
|
|
41
|
+
await Cache.set('b', 2)
|
|
42
|
+
await Cache.clear()
|
|
43
|
+
expect(await Cache.get('a')).toBeNull()
|
|
44
|
+
expect(await Cache.get('b')).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('keys returns all stored keys', async () => {
|
|
48
|
+
await Cache.set('x', 1)
|
|
49
|
+
await Cache.set('y', 2)
|
|
50
|
+
const keys = await Cache.keys()
|
|
51
|
+
expect(keys).toContain('x')
|
|
52
|
+
expect(keys).toContain('y')
|
|
53
|
+
expect(keys).toHaveLength(2)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import Fetch from '../modules/fetch.js'
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
Fetch.configure({ baseUrl: '', onError: null })
|
|
6
|
+
vi.restoreAllMocks()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
describe('Fetch.configure', () => {
|
|
10
|
+
it('sets baseUrl', () => {
|
|
11
|
+
Fetch.configure({ baseUrl: 'https://api.example.com' })
|
|
12
|
+
expect(Fetch._baseUrl).toBe('https://api.example.com')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('sets onError callback', () => {
|
|
16
|
+
const handler = vi.fn()
|
|
17
|
+
Fetch.configure({ onError: handler })
|
|
18
|
+
expect(Fetch._onError).toBe(handler)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('defaults baseUrl to empty string', () => {
|
|
22
|
+
Fetch.configure({})
|
|
23
|
+
expect(Fetch._baseUrl).toBe('')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('Fetch.url', () => {
|
|
28
|
+
it('prepends baseUrl for internal requests', async () => {
|
|
29
|
+
Fetch.configure({ baseUrl: 'https://api.example.com' })
|
|
30
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
31
|
+
json: () => Promise.resolve({ ok: true })
|
|
32
|
+
})
|
|
33
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
34
|
+
|
|
35
|
+
await Fetch.url('/items')
|
|
36
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
37
|
+
'https://api.example.com/items',
|
|
38
|
+
expect.objectContaining({ method: 'GET' })
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('does not prepend baseUrl when internal=false', async () => {
|
|
43
|
+
Fetch.configure({ baseUrl: 'https://api.example.com' })
|
|
44
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
45
|
+
json: () => Promise.resolve({})
|
|
46
|
+
})
|
|
47
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
48
|
+
|
|
49
|
+
await Fetch.url('https://other.com/data', 'GET', null, false)
|
|
50
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
51
|
+
'https://other.com/data',
|
|
52
|
+
expect.anything()
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('sends JSON body for POST requests', async () => {
|
|
57
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
58
|
+
json: () => Promise.resolve({})
|
|
59
|
+
})
|
|
60
|
+
vi.stubGlobal('fetch', mockFetch)
|
|
61
|
+
|
|
62
|
+
await Fetch.url('/items', 'POST', { name: 'test' })
|
|
63
|
+
expect(mockFetch).toHaveBeenCalledWith(
|
|
64
|
+
'/items',
|
|
65
|
+
expect.objectContaining({
|
|
66
|
+
method: 'POST',
|
|
67
|
+
body: JSON.stringify({ name: 'test' })
|
|
68
|
+
})
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null and calls onError on network failure', async () => {
|
|
73
|
+
const onError = vi.fn()
|
|
74
|
+
Fetch.configure({ onError })
|
|
75
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
|
76
|
+
|
|
77
|
+
const result = await Fetch.url('/fail')
|
|
78
|
+
expect(result).toBeNull()
|
|
79
|
+
expect(onError).toHaveBeenCalledOnce()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns null and skips onError when triggerErrorHandler=false', async () => {
|
|
83
|
+
const onError = vi.fn()
|
|
84
|
+
Fetch.configure({ onError })
|
|
85
|
+
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')))
|
|
86
|
+
|
|
87
|
+
const result = await Fetch.url('/fail', 'GET', null, true, false)
|
|
88
|
+
expect(result).toBeNull()
|
|
89
|
+
expect(onError).not.toHaveBeenCalled()
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('returns parsed JSON on success', async () => {
|
|
93
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
94
|
+
json: () => Promise.resolve({ id: 42 })
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
const result = await Fetch.url('/item/42')
|
|
98
|
+
expect(result).toEqual({ id: 42 })
|
|
99
|
+
})
|
|
100
|
+
})
|