metaowl 0.3.0 → 0.3.2
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/bin/metaowl-generate.js +176 -13
- package/modules/templates-manager.js +26 -4
- package/package.json +2 -3
- package/postcss.cjs +8 -23
- package/vite/plugin.js +33 -9
package/bin/metaowl-generate.js
CHANGED
|
@@ -92,15 +92,56 @@ function deriveRoute(pageFile) {
|
|
|
92
92
|
return '/' + parts.join('/')
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Extracts the layout name from a page component JS file.
|
|
97
|
+
* Looks for `static layout = 'layoutName'` or `@layout('layoutName')`.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} pageFile - Absolute path to the page JS file
|
|
100
|
+
* @returns {string|null} Layout name or null if not found
|
|
101
|
+
*/
|
|
102
|
+
function extractLayoutName(pageFile) {
|
|
103
|
+
const jsSource = readFileSync(pageFile, 'utf-8')
|
|
104
|
+
|
|
105
|
+
// Match: static layout = 'layoutName' or static layout = "layoutName"
|
|
106
|
+
let match = jsSource.match(/static\s+layout\s*=\s*['"]([^'"]+)['"]/)
|
|
107
|
+
if (match) return match[1]
|
|
108
|
+
|
|
109
|
+
// Match: @layout('layoutName') or @layout("layoutName")
|
|
110
|
+
match = jsSource.match(/@layout\s*\(\s*['"]([^'"]+)['"]\s*\)/)
|
|
111
|
+
if (match) return match[1]
|
|
112
|
+
|
|
113
|
+
// Match: @defineLayout('layoutName', ...)
|
|
114
|
+
match = jsSource.match(/@defineLayout\s*\(\s*['"]([^'"]+)['"]/)
|
|
115
|
+
if (match) return match[1]
|
|
116
|
+
|
|
117
|
+
return 'default'
|
|
118
|
+
}
|
|
119
|
+
|
|
95
120
|
/**
|
|
96
121
|
* Converts an OWL XML template to best-effort static HTML.
|
|
97
122
|
* - Strips t-name on the root element
|
|
98
123
|
* - Strips all t-* attributes
|
|
99
124
|
* - Unwraps bare <t> elements (replaces with their inner content)
|
|
100
|
-
* - Replaces PascalCase component tags with
|
|
125
|
+
* - Replaces PascalCase component tags with their template content
|
|
126
|
+
* - Replaces t-slot="default" with provided page content
|
|
127
|
+
* - Removes <templates> wrapper and root <t> elements
|
|
128
|
+
*
|
|
129
|
+
* @param {string} xml - OWL XML template
|
|
130
|
+
* @param {string} [pageContent] - Optional page content to inject into t-slot="default"
|
|
131
|
+
* @param {object} [options] - Options object
|
|
132
|
+
* @param {Map<string, string>} [options.templateCache] - Cache of template name -> content
|
|
101
133
|
*/
|
|
102
|
-
function xmlToStaticHtml(xml) {
|
|
134
|
+
function xmlToStaticHtml(xml, pageContent = '', options = {}) {
|
|
135
|
+
const { templateCache } = options
|
|
103
136
|
let html = xml
|
|
137
|
+
|
|
138
|
+
// Remove <templates> wrapper
|
|
139
|
+
html = html.replace(/<templates>/g, '').replace(/<\/templates>/g, '')
|
|
140
|
+
|
|
141
|
+
// Remove root <t> elements (self-closing or with content)
|
|
142
|
+
// Match <t ...>...</t> at the start and end
|
|
143
|
+
html = html.replace(/^\s*<t[^>]*>/, '').replace(/<\/t>\s*$/, '')
|
|
144
|
+
|
|
104
145
|
// Remove t-name attribute from root
|
|
105
146
|
html = html.replace(/\s+t-name="[^"]*"/g, '')
|
|
106
147
|
// Remove all t-* attributes (handles both t-if="..." and bare t-else/t-else)
|
|
@@ -109,10 +150,52 @@ function xmlToStaticHtml(xml) {
|
|
|
109
150
|
html = html.replace(/<t\s*\/>/g, '')
|
|
110
151
|
// Unwrap <t ...> ... </t> blocks — replace opening/closing tags with content
|
|
111
152
|
html = html.replace(/<t(?:\s[^>]*)?>([\s\S]*?)<\/t>/g, (_, inner) => inner)
|
|
112
|
-
// Replace
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
153
|
+
// Replace t-slot="default" with page content (if provided)
|
|
154
|
+
if (pageContent) {
|
|
155
|
+
html = html.replace(/<t\s+t-slot="default"\s*\/?>/g, pageContent)
|
|
156
|
+
html = html.replace(/<t\s+t-slot="default"[^>]*>([\s\S]*?)<\/t>/g, pageContent)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Replace PascalCase component tags with their template content (if available)
|
|
160
|
+
if (templateCache) {
|
|
161
|
+
// Keep replacing until no more changes (for nested components)
|
|
162
|
+
let previousHtml
|
|
163
|
+
do {
|
|
164
|
+
previousHtml = html
|
|
165
|
+
html = html.replace(/<([A-Z][A-Za-z0-9]*)\s*\/>/g, (match, componentName) => {
|
|
166
|
+
// Try different naming conventions
|
|
167
|
+
const templateNames = [
|
|
168
|
+
componentName,
|
|
169
|
+
componentName.charAt(0).toLowerCase() + componentName.slice(1),
|
|
170
|
+
componentName + 'Component',
|
|
171
|
+
]
|
|
172
|
+
for (const name of templateNames) {
|
|
173
|
+
if (templateCache.has(name)) {
|
|
174
|
+
return templateCache.get(name)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return match // Keep original if not found
|
|
178
|
+
})
|
|
179
|
+
html = html.replace(/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>([\s\S]*?)<\/\1>/g, (match, componentName, innerContent) => {
|
|
180
|
+
const templateNames = [
|
|
181
|
+
componentName,
|
|
182
|
+
componentName.charAt(0).toLowerCase() + componentName.slice(1),
|
|
183
|
+
componentName + 'Component',
|
|
184
|
+
]
|
|
185
|
+
for (const name of templateNames) {
|
|
186
|
+
if (templateCache.has(name)) {
|
|
187
|
+
return templateCache.get(name)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return match
|
|
191
|
+
})
|
|
192
|
+
} while (html !== previousHtml)
|
|
193
|
+
} else {
|
|
194
|
+
// No cache available, use comment stubs as fallback
|
|
195
|
+
html = html.replace(/<([A-Z][A-Za-z0-9]*)\s*\/>/g, '<!-- $1 -->')
|
|
196
|
+
html = html.replace(/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, '<!-- $1 -->')
|
|
197
|
+
}
|
|
198
|
+
|
|
116
199
|
return html.trim()
|
|
117
200
|
}
|
|
118
201
|
|
|
@@ -125,16 +208,96 @@ function buildShell(baseHtml, pageFile) {
|
|
|
125
208
|
const meta = extractMetaFromJs(jsSource)
|
|
126
209
|
html = injectMeta(html, meta)
|
|
127
210
|
|
|
128
|
-
//
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
211
|
+
// Get the page's layout name
|
|
212
|
+
const layoutName = extractLayoutName(resolve(cwd, pageFile))
|
|
213
|
+
const layoutsDir = metaowlConfig.layoutsDir ?? 'src/layouts'
|
|
214
|
+
const componentsDir = metaowlConfig.componentsDir ?? 'src/components'
|
|
215
|
+
|
|
216
|
+
// Build template cache from all XML files (components, pages, layouts)
|
|
217
|
+
const templateCache = new Map()
|
|
218
|
+
|
|
219
|
+
// Scan component XML files
|
|
220
|
+
const componentXmlFiles = globSync(`${componentsDir}/**/*.xml`, { cwd })
|
|
221
|
+
for (const componentXmlFile of componentXmlFiles) {
|
|
222
|
+
const content = readFileSync(resolve(cwd, componentXmlFile), 'utf-8')
|
|
223
|
+
// Extract t-name values and their content: <t t-name="ComponentName">...content...</t>
|
|
224
|
+
const tNameMatches = content.matchAll(/<t\s+t-name="([^"]+)"[^>]*>([\s\S]*?)<\/t>/g)
|
|
225
|
+
for (const match of tNameMatches) {
|
|
226
|
+
const templateName = match[1]
|
|
227
|
+
const templateContent = match[2]
|
|
228
|
+
templateCache.set(templateName, templateContent)
|
|
229
|
+
}
|
|
230
|
+
// Also try to get the root element as the template (without t-name)
|
|
231
|
+
const rootMatch = content.match(/<templates>\s*<t[^>]*>([\s\S]*?)<\/t>\s*<\/templates>/)
|
|
232
|
+
if (rootMatch) {
|
|
233
|
+
// Try to derive component name from file path
|
|
234
|
+
const fileName = componentXmlFile.replace(/\.xml$/, '').split('/').pop()
|
|
235
|
+
if (fileName) {
|
|
236
|
+
templateCache.set(fileName, rootMatch[1])
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Also scan layout XML files for templates
|
|
242
|
+
const layoutXmlFiles = globSync(`${layoutsDir}/**/*.xml`, { cwd })
|
|
243
|
+
for (const layoutXmlFile of layoutXmlFiles) {
|
|
244
|
+
const content = readFileSync(resolve(cwd, layoutXmlFile), 'utf-8')
|
|
245
|
+
const tNameMatches = content.matchAll(/<t\s+t-name="([^"]+)"[^>]*>([\s\S]*?)<\/t>/g)
|
|
246
|
+
for (const match of tNameMatches) {
|
|
247
|
+
templateCache.set(match[1], match[2])
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Also scan page XML files for templates
|
|
252
|
+
const pageXmlFiles = globSync(`${pagesDir}/**/*.xml`, { cwd })
|
|
253
|
+
for (const pageXmlFile of pageXmlFiles) {
|
|
254
|
+
const content = readFileSync(resolve(cwd, pageXmlFile), 'utf-8')
|
|
255
|
+
const tNameMatches = content.matchAll(/<t\s+t-name="([^"]+)"[^>]*>([\s\S]*?)<\/t>/g)
|
|
256
|
+
for (const match of tNameMatches) {
|
|
257
|
+
templateCache.set(match[1], match[2])
|
|
258
|
+
}
|
|
259
|
+
// Try to get the root element as the template (without t-name)
|
|
260
|
+
const rootMatch = content.match(/<templates>\s*<t[^>]*>([\s\S]*?)<\/t>\s*<\/templates>/)
|
|
261
|
+
if (rootMatch) {
|
|
262
|
+
const fileName = pageXmlFile.replace(/\.xml$/, '').split('/').pop()
|
|
263
|
+
if (fileName) {
|
|
264
|
+
templateCache.set(fileName, rootMatch[1])
|
|
265
|
+
}
|
|
135
266
|
}
|
|
136
267
|
}
|
|
137
268
|
|
|
269
|
+
// Try to find and read the layout XML template
|
|
270
|
+
let finalContent = ''
|
|
271
|
+
|
|
272
|
+
// First, try to read the layout XML
|
|
273
|
+
const layoutXmlFile = resolve(cwd, layoutsDir, layoutName, `${layoutName.charAt(0).toUpperCase() + layoutName.slice(1)}Layout.xml`)
|
|
274
|
+
const layoutXmlExists = existsSync(layoutXmlFile)
|
|
275
|
+
|
|
276
|
+
// Read the page XML template
|
|
277
|
+
const pageXmlFile = resolve(cwd, pageFile.replace(/\.js$/, '.xml'))
|
|
278
|
+
const pageXmlExists = existsSync(pageXmlFile)
|
|
279
|
+
|
|
280
|
+
if (layoutXmlExists && pageXmlExists) {
|
|
281
|
+
// Read both layout and page templates
|
|
282
|
+
const layoutXmlContent = readFileSync(layoutXmlFile, 'utf-8')
|
|
283
|
+
const pageXmlContent = readFileSync(pageXmlFile, 'utf-8')
|
|
284
|
+
|
|
285
|
+
// Convert page XML to static HTML first
|
|
286
|
+
const pageStaticHtml = xmlToStaticHtml(pageXmlContent, '', { templateCache })
|
|
287
|
+
|
|
288
|
+
// Then inject into layout's t-slot="default"
|
|
289
|
+
finalContent = xmlToStaticHtml(layoutXmlContent, pageStaticHtml, { templateCache })
|
|
290
|
+
} else if (pageXmlExists) {
|
|
291
|
+
// No layout found, just use page content
|
|
292
|
+
const pageXmlContent = readFileSync(pageXmlFile, 'utf-8')
|
|
293
|
+
finalContent = xmlToStaticHtml(pageXmlContent, '', { templateCache })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Inject the combined layout + page HTML
|
|
297
|
+
if (finalContent) {
|
|
298
|
+
html = html.replace(/(<div\s+id="metaowl"[^>]*>)(<\/div>)/, `$1${finalContent}$2`)
|
|
299
|
+
}
|
|
300
|
+
|
|
138
301
|
return html
|
|
139
302
|
}
|
|
140
303
|
|
|
@@ -6,15 +6,37 @@
|
|
|
6
6
|
import { loadFile } from '@odoo/owl'
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
* Loads
|
|
10
|
-
* `<templates>` string ready to be passed to OWL's mount() options.
|
|
9
|
+
* Loads OWL XML template(s) into a string ready to be passed to OWL's mount() options.
|
|
11
10
|
*
|
|
12
|
-
*
|
|
11
|
+
* If a single file is provided that already contains <templates> wrapper (merged file),
|
|
12
|
+
* it's returned as-is. Otherwise, the content is wrapped in <templates>.
|
|
13
|
+
*
|
|
14
|
+
* @param {string|string[]} files - Array of URL-style XML paths or single path
|
|
13
15
|
* @returns {Promise<string>}
|
|
14
16
|
*/
|
|
15
17
|
export async function mergeTemplates(files) {
|
|
18
|
+
// Normalize to array
|
|
19
|
+
const fileArray = Array.isArray(files) ? files : [files]
|
|
20
|
+
|
|
21
|
+
// If there's only one file, check if it's already wrapped
|
|
22
|
+
if (fileArray.length === 1) {
|
|
23
|
+
try {
|
|
24
|
+
const content = await loadFile(fileArray[0])
|
|
25
|
+
// If already wrapped (merged templates.xml), return as-is
|
|
26
|
+
if (content.trim().startsWith('<templates>')) {
|
|
27
|
+
return content
|
|
28
|
+
}
|
|
29
|
+
// Otherwise wrap it
|
|
30
|
+
return '<templates>' + content + '</templates>'
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.error(`[metaowl] Failed to load template: ${fileArray[0]}`, e)
|
|
33
|
+
return '<templates></templates>'
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Multiple files: load each and wrap in <templates>
|
|
16
38
|
const results = await Promise.all(
|
|
17
|
-
|
|
39
|
+
fileArray.map(async (file) => {
|
|
18
40
|
try {
|
|
19
41
|
return await loadFile(file)
|
|
20
42
|
} catch (e) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metaowl",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -37,7 +37,6 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@eslint/js": "^9.20.1",
|
|
40
|
-
"@fullhuman/postcss-purgecss": "^6.0.0",
|
|
41
40
|
"@odoo/owl": "^2.8.2",
|
|
42
41
|
"@typescript-eslint/eslint-plugin": "^8.24.1",
|
|
43
42
|
"@typescript-eslint/parser": "^8.24.1",
|
|
@@ -54,7 +53,7 @@
|
|
|
54
53
|
"vite-tsconfig-paths": "^6.1.1"
|
|
55
54
|
},
|
|
56
55
|
"engines": {
|
|
57
|
-
"node": ">=
|
|
56
|
+
"node": ">=20.0.0"
|
|
58
57
|
},
|
|
59
58
|
"scripts": {
|
|
60
59
|
"test": "vitest run",
|
package/postcss.cjs
CHANGED
|
@@ -4,37 +4,22 @@
|
|
|
4
4
|
// const { createPostcssConfig } = require('metaowl/postcss')
|
|
5
5
|
// module.exports = createPostcssConfig()
|
|
6
6
|
//
|
|
7
|
-
//
|
|
7
|
+
// Add extra PostCSS plugins:
|
|
8
8
|
//
|
|
9
9
|
// module.exports = createPostcssConfig({
|
|
10
|
-
//
|
|
11
|
-
// content: ['./templates/**/*.html']
|
|
10
|
+
// additionalPlugins: [require('some-postcss-plugin')()]
|
|
12
11
|
// })
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
//
|
|
13
|
+
// Note: PurgeCSS is intentionally not included. Tailwind CSS v4 performs its
|
|
14
|
+
// own content scanning and generates only the CSS that is actually used.
|
|
15
|
+
// Adding PurgeCSS on top breaks responsive variants (sm:, md:, lg:, etc.)
|
|
16
|
+
// because its default extractor treats ":" as a separator.
|
|
15
17
|
|
|
16
18
|
function createPostcssConfig(options = {}) {
|
|
17
|
-
const {
|
|
18
|
-
safelist = [],
|
|
19
|
-
content = [],
|
|
20
|
-
additionalPlugins = []
|
|
21
|
-
} = options
|
|
19
|
+
const { additionalPlugins = [] } = options
|
|
22
20
|
|
|
23
21
|
return {
|
|
24
22
|
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
23
|
...additionalPlugins
|
|
39
24
|
]
|
|
40
25
|
}
|
package/vite/plugin.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url'
|
|
2
2
|
import { resolve, dirname } from 'node:path'
|
|
3
|
-
import { mkdirSync, copyFileSync, cpSync, existsSync } from 'node:fs'
|
|
3
|
+
import { mkdirSync, copyFileSync, cpSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
4
4
|
import { createRequire } from 'node:module'
|
|
5
5
|
import { globSync } from 'glob'
|
|
6
6
|
import { config as dotenvConfig } from 'dotenv'
|
|
@@ -25,6 +25,33 @@ function collectXml(globPattern) {
|
|
|
25
25
|
return globSync(globPattern).map(p => p.replace(/^src[\\/]/, '/'))
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Merge all XML template files into a single XML string.
|
|
30
|
+
* Removes <templates> wrappers from individual files and wraps everything in a single <templates>.
|
|
31
|
+
* The result is minified to reduce file size.
|
|
32
|
+
*
|
|
33
|
+
* @param {string[]} xmlPaths - Array of absolute file paths to XML files
|
|
34
|
+
* @returns {string} Merged XML string
|
|
35
|
+
*/
|
|
36
|
+
function mergeXmlFiles(xmlPaths) {
|
|
37
|
+
const templates = xmlPaths.map(filePath => {
|
|
38
|
+
try {
|
|
39
|
+
let content = readFileSync(filePath, 'utf-8')
|
|
40
|
+
// Remove <templates> wrapper if present in individual file
|
|
41
|
+
content = content.replace(/<templates>/g, '').replace(/<\/templates>/g, '')
|
|
42
|
+
return content
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(`[metaowl] Failed to read XML file: ${filePath}`, e)
|
|
45
|
+
return ''
|
|
46
|
+
}
|
|
47
|
+
}).join('')
|
|
48
|
+
|
|
49
|
+
// Minify: remove unnecessary whitespace while keeping valid XML structure
|
|
50
|
+
const minified = '<templates>' + templates.replace(/\s+/g, ' ').replace(/>\s+</g, '><').trim() + '</templates>'
|
|
51
|
+
|
|
52
|
+
return minified
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
/**
|
|
29
56
|
* metaowl Vite plugin.
|
|
30
57
|
*
|
|
@@ -119,7 +146,7 @@ export async function metaowlPlugin(options = {}) {
|
|
|
119
146
|
cfg.define = {
|
|
120
147
|
...(cfg.define ?? {}),
|
|
121
148
|
DEV_MODE: isDev,
|
|
122
|
-
COMPONENTS: JSON.stringify(allComponents),
|
|
149
|
+
COMPONENTS: JSON.stringify(isDev ? allComponents : ['/templates.xml']),
|
|
123
150
|
'process.env': safeEnv
|
|
124
151
|
}
|
|
125
152
|
|
|
@@ -200,14 +227,11 @@ export async function metaowlPlugin(options = {}) {
|
|
|
200
227
|
closeBundle() {
|
|
201
228
|
const projectRoot = process.cwd()
|
|
202
229
|
|
|
203
|
-
//
|
|
230
|
+
// Merge all OWL XML templates into a single file
|
|
204
231
|
const xmlFiles = globSync([`${componentsDir}/**/*.xml`, `${pagesDir}/**/*.xml`, `${layoutsDir}/**/*.xml`])
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
mkdirSync(dirname(dest), { recursive: true })
|
|
209
|
-
copyFileSync(resolve(projectRoot, xmlFile), dest)
|
|
210
|
-
}
|
|
232
|
+
const mergedXml = mergeXmlFiles(xmlFiles)
|
|
233
|
+
const templatesPath = resolve(_outDirResolved, 'templates.xml')
|
|
234
|
+
writeFileSync(templatesPath, mergedXml, 'utf-8')
|
|
211
235
|
|
|
212
236
|
// Copy assets/images (referenced via absolute URLs in XML — not processed by Vite)
|
|
213
237
|
const srcImages = resolve(projectRoot, root, 'assets', 'images')
|