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.
@@ -0,0 +1,55 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { buildRoutes } from '../modules/file-router.js'
3
+
4
+ const Comp = function IndexPage() {}
5
+ const About = function AboutPage() {}
6
+ const Deep = function DeepPage() {}
7
+
8
+ describe('buildRoutes', () => {
9
+ it('maps index directory to /', () => {
10
+ const routes = buildRoutes({
11
+ './pages/index/Index.js': { default: Comp }
12
+ })
13
+ expect(routes).toHaveLength(1)
14
+ expect(routes[0].path).toContain('/')
15
+ expect(routes[0].name).toBe('index')
16
+ expect(routes[0].component).toBe(Comp)
17
+ })
18
+
19
+ it('maps a named directory to its URL path', () => {
20
+ const routes = buildRoutes({
21
+ './pages/about/About.js': { default: About }
22
+ })
23
+ expect(routes[0].path).toContain('/about')
24
+ expect(routes[0].name).toBe('about')
25
+ })
26
+
27
+ it('maps nested directories to a slug name', () => {
28
+ const routes = buildRoutes({
29
+ './pages/blog/post/Post.js': { default: Deep }
30
+ })
31
+ expect(routes[0].path).toContain('/blog/post')
32
+ expect(routes[0].name).toBe('blog-post')
33
+ })
34
+
35
+ it('falls back to first function export when no default', () => {
36
+ const routes = buildRoutes({
37
+ './pages/index/Index.js': { MyComp: Comp }
38
+ })
39
+ expect(routes[0].component).toBe(Comp)
40
+ })
41
+
42
+ it('throws when module has no function export', () => {
43
+ expect(() =>
44
+ buildRoutes({ './pages/index/Index.js': { value: 42 } })
45
+ ).toThrow('[metaowl]')
46
+ })
47
+
48
+ it('builds multiple routes from multiple modules', () => {
49
+ const routes = buildRoutes({
50
+ './pages/index/Index.js': { default: Comp },
51
+ './pages/about/About.js': { default: About }
52
+ })
53
+ expect(routes).toHaveLength(2)
54
+ })
55
+ })
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { JSDOM } from 'jsdom'
3
+ import * as Meta from '../modules/meta.js'
4
+
5
+ let dom
6
+
7
+ beforeEach(() => {
8
+ dom = new JSDOM('<!doctype html><html><head></head><body></body></html>')
9
+ globalThis.document = dom.window.document
10
+ })
11
+
12
+ describe('Meta.title', () => {
13
+ it('sets document.title', () => {
14
+ Meta.title('My Page')
15
+ expect(document.title).toBe('My Page')
16
+ })
17
+
18
+ it('updates title on second call', () => {
19
+ Meta.title('First')
20
+ Meta.title('Second')
21
+ expect(document.title).toBe('Second')
22
+ })
23
+
24
+ it('does nothing when called with empty string', () => {
25
+ document.title = 'Original'
26
+ Meta.title('')
27
+ expect(document.title).toBe('Original')
28
+ })
29
+ })
30
+
31
+ describe('Meta name-based tags (description, keywords, author)', () => {
32
+ it('creates a meta[name="description"] tag', () => {
33
+ Meta.description('A test page')
34
+ const el = document.querySelector('meta[name="description"]')
35
+ expect(el).not.toBeNull()
36
+ expect(el.content).toBe('A test page')
37
+ })
38
+
39
+ it('updates description content on second call', () => {
40
+ Meta.description('First')
41
+ Meta.description('Second')
42
+ const els = document.querySelectorAll('meta[name="description"]')
43
+ expect(els).toHaveLength(1)
44
+ expect(els[0].content).toBe('Second')
45
+ })
46
+
47
+ it('creates meta[name="keywords"]', () => {
48
+ Meta.keywords('owl, odoo')
49
+ expect(document.querySelector('meta[name="keywords"]').content).toBe('owl, odoo')
50
+ })
51
+
52
+ it('creates meta[name="author"]', () => {
53
+ Meta.author('Dennis')
54
+ expect(document.querySelector('meta[name="author"]').content).toBe('Dennis')
55
+ })
56
+
57
+ it('does nothing for falsy value', () => {
58
+ Meta.description(null)
59
+ expect(document.querySelector('meta[name="description"]')).toBeNull()
60
+ })
61
+ })
62
+
63
+ describe('Meta.canonical', () => {
64
+ it('creates a link[rel="canonical"]', () => {
65
+ Meta.canonical('https://example.com/page')
66
+ const el = document.querySelector('link[rel="canonical"]')
67
+ expect(el).not.toBeNull()
68
+ expect(el.href).toBe('https://example.com/page')
69
+ })
70
+
71
+ it('updates href on second call without duplicating', () => {
72
+ Meta.canonical('https://example.com/a')
73
+ Meta.canonical('https://example.com/b')
74
+ const els = document.querySelectorAll('link[rel="canonical"]')
75
+ expect(els).toHaveLength(1)
76
+ expect(els[0].href).toBe('https://example.com/b')
77
+ })
78
+ })
79
+
80
+ describe('Meta Open Graph tags', () => {
81
+ it('creates og:title', () => {
82
+ Meta.ogTitle('OG Title')
83
+ expect(document.querySelector('meta[property="og:title"]').content).toBe('OG Title')
84
+ })
85
+
86
+ it('updates og:title on second call without duplicating', () => {
87
+ Meta.ogTitle('First')
88
+ Meta.ogTitle('Second')
89
+ expect(document.querySelectorAll('meta[property="og:title"]')).toHaveLength(1)
90
+ expect(document.querySelector('meta[property="og:title"]').content).toBe('Second')
91
+ })
92
+
93
+ it('creates og:description', () => {
94
+ Meta.ogDescription('Desc')
95
+ expect(document.querySelector('meta[property="og:description"]').content).toBe('Desc')
96
+ })
97
+
98
+ it('creates og:image', () => {
99
+ Meta.ogImage('https://example.com/img.png')
100
+ expect(document.querySelector('meta[property="og:image"]').content).toBe('https://example.com/img.png')
101
+ })
102
+
103
+ it('creates og:url', () => {
104
+ Meta.ogUrl('https://example.com')
105
+ expect(document.querySelector('meta[property="og:url"]').content).toBe('https://example.com')
106
+ })
107
+
108
+ it('creates og:type', () => {
109
+ Meta.ogType('website')
110
+ expect(document.querySelector('meta[property="og:type"]').content).toBe('website')
111
+ })
112
+
113
+ it('creates og:site_name', () => {
114
+ Meta.ogSiteName('My Site')
115
+ expect(document.querySelector('meta[property="og:site_name"]').content).toBe('My Site')
116
+ })
117
+ })
118
+
119
+ describe('Meta Twitter Card tags', () => {
120
+ it('creates twitter:card', () => {
121
+ Meta.twitterCard('summary_large_image')
122
+ expect(document.querySelector('meta[name="twitter:card"]').content).toBe('summary_large_image')
123
+ })
124
+
125
+ it('creates twitter:title', () => {
126
+ Meta.twitterTitle('TW Title')
127
+ expect(document.querySelector('meta[name="twitter:title"]').content).toBe('TW Title')
128
+ })
129
+
130
+ it('creates twitter:description', () => {
131
+ Meta.twitterDescription('TW Desc')
132
+ expect(document.querySelector('meta[name="twitter:description"]').content).toBe('TW Desc')
133
+ })
134
+
135
+ it('creates twitter:image', () => {
136
+ Meta.twitterImage('https://example.com/tw.png')
137
+ expect(document.querySelector('meta[name="twitter:image"]').content).toBe('https://example.com/tw.png')
138
+ })
139
+
140
+ it('updates twitter:card on second call without duplicating', () => {
141
+ Meta.twitterCard('summary')
142
+ Meta.twitterCard('summary_large_image')
143
+ expect(document.querySelectorAll('meta[name="twitter:card"]')).toHaveLength(1)
144
+ expect(document.querySelector('meta[name="twitter:card"]').content).toBe('summary_large_image')
145
+ })
146
+ })
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest'
2
+ import { processRoutes } from '../modules/router.js'
3
+
4
+ const Comp = function Page() {}
5
+
6
+ function makeRoute(path) {
7
+ return { name: 'page', path: [path], component: Comp }
8
+ }
9
+
10
+ // Stub document.location for each test
11
+ function setPath(pathname) {
12
+ Object.defineProperty(globalThis, 'document', {
13
+ value: { location: { pathname } },
14
+ writable: true,
15
+ configurable: true
16
+ })
17
+ }
18
+
19
+ beforeEach(() => {
20
+ setPath('/')
21
+ })
22
+
23
+ describe('processRoutes', () => {
24
+ it('resolves a matching route for /', async () => {
25
+ setPath('/')
26
+ const routes = [makeRoute('/')]
27
+ const result = await processRoutes(routes)
28
+ expect(result).toHaveLength(1)
29
+ expect(result[0].component).toBe(Comp)
30
+ })
31
+
32
+ it('resolves /index.html as the index route', async () => {
33
+ setPath('/index.html')
34
+ const routes = [makeRoute('/')]
35
+ const result = await processRoutes(routes)
36
+ expect(result[0].component).toBe(Comp)
37
+ })
38
+
39
+ it('resolves /about via trailing slash variant', async () => {
40
+ setPath('/about/')
41
+ const routes = [makeRoute('/about')]
42
+ const result = await processRoutes(routes)
43
+ expect(result[0].component).toBe(Comp)
44
+ })
45
+
46
+ it('resolves /about.html variant', async () => {
47
+ setPath('/about.html')
48
+ const routes = [makeRoute('/about')]
49
+ const result = await processRoutes(routes)
50
+ expect(result[0].component).toBe(Comp)
51
+ })
52
+
53
+ it('resolves /about/index.html variant', async () => {
54
+ setPath('/about/index.html')
55
+ const routes = [makeRoute('/about')]
56
+ const result = await processRoutes(routes)
57
+ expect(result[0].component).toBe(Comp)
58
+ })
59
+
60
+ it('throws when no route matches', async () => {
61
+ setPath('/not-found')
62
+ const routes = [makeRoute('/')]
63
+ await expect(processRoutes(routes)).rejects.toThrow('No route found')
64
+ })
65
+
66
+ it('does not duplicate SSG paths on repeated calls', async () => {
67
+ setPath('/')
68
+ const route = makeRoute('/')
69
+ await processRoutes([route])
70
+ const countBefore = route.path.length
71
+ setPath('/index.html')
72
+ await processRoutes([route])
73
+ // /index.html should still only appear once
74
+ const indexHtmlCount = route.path.filter(p => p === '/index.html').length
75
+ expect(indexHtmlCount).toBe(1)
76
+ })
77
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+ import { mergeTemplates } from '../modules/templates-manager.js'
3
+
4
+ vi.mock('@odoo/owl', () => ({
5
+ loadFile: vi.fn(),
6
+ }))
7
+
8
+ import { loadFile } from '@odoo/owl'
9
+
10
+ beforeEach(() => {
11
+ vi.clearAllMocks()
12
+ })
13
+
14
+ describe('mergeTemplates', () => {
15
+ it('returns wrapped templates string from a single file', async () => {
16
+ loadFile.mockResolvedValue('<t t-name="Comp"><div/></t>')
17
+ const result = await mergeTemplates(['/components/Comp.xml'])
18
+ expect(result).toBe('<templates><t t-name="Comp"><div/></t></templates>')
19
+ })
20
+
21
+ it('concatenates multiple template files', async () => {
22
+ loadFile
23
+ .mockResolvedValueOnce('<t t-name="A"><div/></t>')
24
+ .mockResolvedValueOnce('<t t-name="B"><span/></t>')
25
+ const result = await mergeTemplates(['/A.xml', '/B.xml'])
26
+ expect(result).toBe('<templates><t t-name="A"><div/></t><t t-name="B"><span/></t></templates>')
27
+ })
28
+
29
+ it('returns empty templates tag for empty array', async () => {
30
+ const result = await mergeTemplates([])
31
+ expect(result).toBe('<templates></templates>')
32
+ })
33
+
34
+ it('skips failed files and logs error', async () => {
35
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
36
+ loadFile.mockRejectedValue(new Error('404'))
37
+ const result = await mergeTemplates(['/missing.xml'])
38
+ expect(result).toBe('<templates></templates>')
39
+ expect(consoleSpy).toHaveBeenCalledWith(
40
+ expect.stringContaining('[metaowl] Failed to load template: /missing.xml'),
41
+ expect.any(Error)
42
+ )
43
+ consoleSpy.mockRestore()
44
+ })
45
+
46
+ it('collects successful files even when one fails', async () => {
47
+ const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
48
+ loadFile
49
+ .mockResolvedValueOnce('<t t-name="Good"><div/></t>')
50
+ .mockRejectedValueOnce(new Error('404'))
51
+ .mockResolvedValueOnce('<t t-name="Also"><span/></t>')
52
+ const result = await mergeTemplates(['/good.xml', '/missing.xml', '/also.xml'])
53
+ expect(result).toBe('<templates><t t-name="Good"><div/></t><t t-name="Also"><span/></t></templates>')
54
+ consoleSpy.mockRestore()
55
+ })
56
+
57
+ it('calls loadFile once per path', async () => {
58
+ loadFile.mockResolvedValue('<t/>')
59
+ await mergeTemplates(['/a.xml', '/b.xml', '/c.xml'])
60
+ expect(loadFile).toHaveBeenCalledTimes(3)
61
+ })
62
+ })
package/vite/plugin.js ADDED
@@ -0,0 +1,216 @@
1
+ import { fileURLToPath } from 'node:url'
2
+ import { resolve, dirname } from 'node:path'
3
+ import { mkdirSync, copyFileSync, cpSync, existsSync } from 'node:fs'
4
+ import { globSync } from 'glob'
5
+ import { config as dotenvConfig } from 'dotenv'
6
+ import ViteRestart from 'vite-plugin-restart'
7
+ import tsconfigPaths from 'vite-tsconfig-paths'
8
+
9
+ /**
10
+ * Collect all .xml files from a directory glob and return them as
11
+ * URL-style paths (e.g. /components/Header/Header.xml).
12
+ *
13
+ * @param {string} globPattern - e.g. 'src/components/**\/*.xml'
14
+ * @returns {string[]}
15
+ */
16
+ function collectXml(globPattern) {
17
+ return globSync(globPattern).map(p => p.replace(/^src[\\/]/, '/'))
18
+ }
19
+
20
+ /**
21
+ * metaowl Vite plugin.
22
+ *
23
+ * @param {object} [options]
24
+ * @param {string} [options.root='src'] - Vite root directory.
25
+ * @param {string} [options.outDir='../dist'] - Build output directory.
26
+ * @param {string} [options.publicDir='../public'] - Public assets directory.
27
+ * @param {string} [options.componentsDir='src/components'] - OWL components directory.
28
+ * @param {string} [options.pagesDir='src/pages'] - OWL pages directory.
29
+ * @param {string[]} [options.restartGlobs] - Additional globs that trigger dev-server restart.
30
+ * @param {string} [options.frameworkEntry] - Framework entry for manual chunk.
31
+ * @param {string[]} [options.vendorPackages] - npm packages bundled into the vendor chunk.
32
+ * @param {string} [options.envPrefix] - Only expose env vars with this prefix (plus NODE_ENV) via process.env. Defaults to nothing extra (only NODE_ENV).
33
+ * @returns {import('vite').Plugin[]}
34
+ */
35
+ export function metaowlPlugin(options = {}) {
36
+ const {
37
+ root = 'src',
38
+ outDir = '../dist',
39
+ publicDir = '../public',
40
+ componentsDir = 'src/components',
41
+ pagesDir = 'src/pages',
42
+ restartGlobs = [],
43
+ frameworkEntry = './node_modules/metaowl/index.js',
44
+ vendorPackages = ['@odoo/owl']
45
+ } = options
46
+
47
+ const componentXml = collectXml(`${componentsDir}/**/*.xml`)
48
+ const pageXml = collectXml(`${pagesDir}/**/*.xml`)
49
+ const allComponents = [...pageXml, ...componentXml]
50
+
51
+ const defaultRestartGlobs = [
52
+ `${root}/**/*.[jt]s`,
53
+ `${root}/**/*.xml`,
54
+ `${root}/**/*.html`,
55
+ `${root}/**/*.css`,
56
+ `${root}/**/*.scss`
57
+ ]
58
+
59
+ let _outDirResolved = null
60
+
61
+ return [
62
+ tsconfigPaths({ root: process.cwd() }),
63
+ ViteRestart({
64
+ restart: [...defaultRestartGlobs, ...restartGlobs]
65
+ }),
66
+ {
67
+ name: 'metaowl:define',
68
+ config(cfg, { mode }) {
69
+ // Load .env file from project root
70
+ dotenvConfig()
71
+
72
+ const isDev = mode === 'development'
73
+
74
+ // Expose only NODE_ENV + vars matching the configured prefix.
75
+ // Never expose the full system env to avoid leaking secrets.
76
+ const { envPrefix } = options
77
+ const safeEnv = Object.fromEntries(
78
+ Object.entries(process.env).filter(([k]) =>
79
+ k === 'NODE_ENV' || (envPrefix && k.startsWith(envPrefix))
80
+ )
81
+ )
82
+
83
+ cfg.define = {
84
+ ...(cfg.define ?? {}),
85
+ DEV_MODE: isDev,
86
+ COMPONENTS: JSON.stringify(allComponents),
87
+ 'process.env': safeEnv
88
+ }
89
+
90
+ cfg.root = cfg.root ?? root
91
+ cfg.publicDir = cfg.publicDir ?? publicDir
92
+ cfg.appType = cfg.appType ?? 'spa'
93
+
94
+ const owlPath = fileURLToPath(new URL('../node_modules/@odoo/owl/dist/owl.es.js', import.meta.url))
95
+ cfg.resolve = {
96
+ ...(cfg.resolve ?? {}),
97
+ alias: {
98
+ ...(cfg.resolve?.alias ?? {}),
99
+ '@odoo/owl': owlPath
100
+ }
101
+ }
102
+
103
+ cfg.build = {
104
+ outDir,
105
+ emptyOutDir: true,
106
+ sourcemap: isDev,
107
+ chunkSizeWarningLimit: 1024,
108
+ target: 'esnext',
109
+ rollupOptions: {
110
+ input: resolve(process.cwd(), root, 'index.html'),
111
+ output: {
112
+ manualChunks: {
113
+ vendor: vendorPackages,
114
+ framework: [frameworkEntry]
115
+ }
116
+ }
117
+ },
118
+ ...(cfg.build ?? {})
119
+ }
120
+
121
+ cfg.optimizeDeps = {
122
+ include: ['@odoo/owl'],
123
+ entries: [
124
+ `${componentsDir}/**/*.[jt]s`,
125
+ `${pagesDir}/**/*.[jt]s`
126
+ ],
127
+ ...(cfg.optimizeDeps ?? {})
128
+ }
129
+ },
130
+ configResolved(resolvedConfig) {
131
+ _outDirResolved = resolve(resolvedConfig.root, resolvedConfig.build.outDir)
132
+ }
133
+ },
134
+ {
135
+ name: 'metaowl:app',
136
+ transform(code, id) {
137
+ if (!id.endsWith('/metaowl.js')) return
138
+ const pagesRel = pagesDir.replace(new RegExp(`^${root}[\\/]`), '')
139
+ return {
140
+ code: code.replace(
141
+ /boot\(\s*\)/,
142
+ `boot(import.meta.glob('./${pagesRel}/**/*.js', { eager: true }))`
143
+ ),
144
+ map: null
145
+ }
146
+ }
147
+ },
148
+ {
149
+ name: 'metaowl:styles',
150
+ transform(code, id) {
151
+ if (!id.endsWith('/css.js')) return
152
+ const compRel = componentsDir.replace(new RegExp(`^${root}[\\/]`), '')
153
+ const pagesRel = pagesDir.replace(new RegExp(`^${root}[\\/]`), '')
154
+ return {
155
+ code: code + '\n' +
156
+ `import.meta.glob('/${compRel}/**/*.{css,scss}', { eager: true })\n` +
157
+ `import.meta.glob('/${pagesRel}/**/*.{css,scss}', { eager: true })\n`,
158
+ map: null
159
+ }
160
+ }
161
+ },
162
+ {
163
+ name: 'metaowl:copy-assets',
164
+ apply: 'build',
165
+ closeBundle() {
166
+ const projectRoot = process.cwd()
167
+
168
+ // Copy OWL XML templates (loaded at runtime via fetch — not processed by Vite)
169
+ const xmlFiles = globSync([`${componentsDir}/**/*.xml`, `${pagesDir}/**/*.xml`])
170
+ for (const xmlFile of xmlFiles) {
171
+ const relPath = xmlFile.replace(new RegExp(`^${root}[\\/]`), '')
172
+ const dest = resolve(_outDirResolved, relPath)
173
+ mkdirSync(dirname(dest), { recursive: true })
174
+ copyFileSync(resolve(projectRoot, xmlFile), dest)
175
+ }
176
+
177
+ // Copy assets/images (referenced via absolute URLs in XML — not processed by Vite)
178
+ const srcImages = resolve(projectRoot, root, 'assets', 'images')
179
+ if (existsSync(srcImages)) {
180
+ cpSync(srcImages, resolve(_outDirResolved, 'assets', 'images'), { recursive: true })
181
+ }
182
+ }
183
+ }
184
+ ]
185
+ }
186
+
187
+ /**
188
+ * Convenience wrapper that returns a complete Vite config with metaowl defaults.
189
+ * All options except `server`, `preview` and `build` are forwarded to metaowlPlugin().
190
+ *
191
+ * Usage in vite.config.js:
192
+ *
193
+ * import { metaowlConfig } from 'metaowl/vite'
194
+ * export default metaowlConfig({
195
+ * server: { port: 3333 },
196
+ * preview: { port: 8095 },
197
+ * envPrefix: 'MY_',
198
+ * vendorPackages: ['@odoo/owl', 'apexcharts']
199
+ * })
200
+ *
201
+ * @param {object} [options]
202
+ * @param {object} [options.server] - Vite server config overrides (merged with defaults).
203
+ * @param {object} [options.preview] - Vite preview config overrides (merged with defaults).
204
+ * @param {object} [options.build] - Vite build config overrides.
205
+ * @param {*} [options.*] - All other options forwarded to metaowlPlugin().
206
+ * @returns {import('vite').UserConfig}
207
+ */
208
+ export function metaowlConfig(options = {}) {
209
+ const { server, preview, build, ...metaowlOptions } = options
210
+ return {
211
+ server: { port: 3000, strictPort: true, host: true, ...server },
212
+ preview: { port: 4173, strictPort: true, ...preview },
213
+ ...(build ? { build } : {}),
214
+ plugins: [...metaowlPlugin(metaowlOptions)]
215
+ }
216
+ }
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from 'vitest/config'
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: 'jsdom',
6
+ include: ['test/**/*.test.js']
7
+ }
8
+ })