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.
@@ -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 an HTML comment placeholder
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 PascalCase component tags with a comment stub
113
- // Matches <ComponentName ... /> and <ComponentName ...></ComponentName>
114
- html = html.replace(/<([A-Z][A-Za-z0-9]*)\s*\/>/g, '<!-- $1 -->')
115
- html = html.replace(/<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>/g, '<!-- $1 -->')
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
- // Inject static HTML from OWL XML template (auto-extracted, best-effort)
129
- const xmlFile = resolve(cwd, pageFile.replace(/\.js$/, '.xml'))
130
- if (existsSync(xmlFile)) {
131
- const xmlContent = readFileSync(xmlFile, 'utf-8')
132
- const staticHtml = xmlToStaticHtml(xmlContent)
133
- if (staticHtml) {
134
- html = html.replace(/(<div\s+id="metaowl"[^>]*>)(<\/div>)/, `$1${staticHtml}$2`)
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 and concatenates a list of OWL XML template files into a single
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
- * @param {string[]} files - Array of URL-style XML paths, e.g. ['/owl/components/Header/Header.xml']
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
- files.map(async (file) => {
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.0",
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": ">=18.0.0"
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
- // Override safelist or add content globs:
7
+ // Add extra PostCSS plugins:
8
8
  //
9
9
  // module.exports = createPostcssConfig({
10
- // safelist: [/^my-custom-class/],
11
- // content: ['./templates/**/*.html']
10
+ // additionalPlugins: [require('some-postcss-plugin')()]
12
11
  // })
13
-
14
- const defaultSafelist = []
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
- // Copy OWL XML templates (loaded at runtime via fetch — not processed by Vite)
230
+ // Merge all OWL XML templates into a single file
204
231
  const xmlFiles = globSync([`${componentsDir}/**/*.xml`, `${pagesDir}/**/*.xml`, `${layoutsDir}/**/*.xml`])
205
- for (const xmlFile of xmlFiles) {
206
- const relPath = xmlFile.replace(new RegExp(`^${root}[\\/]`), '')
207
- const dest = resolve(_outDirResolved, relPath)
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')