Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
397
pkg/server/templates.go
Normal file
397
pkg/server/templates.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// compileXMLTemplates reads all XML template files listed in assets_xml.txt,
|
||||
// parses them, and generates a JS module with registerTemplate() /
|
||||
// registerTemplateExtension() calls -- the same output that
|
||||
// tools/compile_templates.py used to produce at build time.
|
||||
//
|
||||
// Mirrors: odoo/addons/base/models/assetsbundle.py generate_xml_bundle()
|
||||
func compileXMLTemplates(frontendDir string) string {
|
||||
// xmlFiles is populated by init() in webclient.go from the embedded
|
||||
// assets_xml.txt file.
|
||||
|
||||
// Collect blocks of templates and extensions to preserve ordering
|
||||
// semantics that Odoo relies on (primary templates first, then
|
||||
// extensions, interleaved per-file).
|
||||
type primaryEntry struct {
|
||||
name string
|
||||
url string
|
||||
xmlStr string
|
||||
inheritFrom string
|
||||
}
|
||||
type extensionEntry struct {
|
||||
inheritFrom string
|
||||
url string
|
||||
xmlStr string
|
||||
}
|
||||
type block struct {
|
||||
kind string // "templates" or "extensions"
|
||||
templates []primaryEntry
|
||||
extensions []extensionEntry
|
||||
}
|
||||
|
||||
var blocks []block
|
||||
var curBlock *block
|
||||
templateCount := 0
|
||||
errorCount := 0
|
||||
|
||||
for _, urlPath := range xmlFiles {
|
||||
content := readFileFromFrontend(frontendDir, urlPath)
|
||||
if content == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse the XML: we need the immediate children of the root element
|
||||
// (typically <templates>). Each child with t-name is a primary
|
||||
// template; each with t-inherit + t-inherit-mode="extension" is an
|
||||
// extension.
|
||||
elements, err := parseXMLTemplateFile(content)
|
||||
if err != nil {
|
||||
log.Printf("templates: ERROR parsing %s: %v", urlPath, err)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
|
||||
for _, elem := range elements {
|
||||
tName := elemAttr(elem, "t-name")
|
||||
tInherit := elemAttr(elem, "t-inherit")
|
||||
tInheritMode := ""
|
||||
if tInherit != "" {
|
||||
tInheritMode = elemAttr(elem, "t-inherit-mode")
|
||||
if tInheritMode == "" {
|
||||
tInheritMode = "primary"
|
||||
}
|
||||
if tInheritMode != "primary" && tInheritMode != "extension" {
|
||||
log.Printf("templates: ERROR invalid inherit mode %q in %s template %s", tInheritMode, urlPath, tName)
|
||||
errorCount++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
serialized := serializeElement(elem)
|
||||
|
||||
if tInheritMode == "extension" {
|
||||
if curBlock == nil || curBlock.kind != "extensions" {
|
||||
blocks = append(blocks, block{kind: "extensions"})
|
||||
curBlock = &blocks[len(blocks)-1]
|
||||
}
|
||||
curBlock.extensions = append(curBlock.extensions, extensionEntry{
|
||||
inheritFrom: tInherit,
|
||||
url: urlPath,
|
||||
xmlStr: serialized,
|
||||
})
|
||||
templateCount++
|
||||
} else if tName != "" {
|
||||
if curBlock == nil || curBlock.kind != "templates" {
|
||||
blocks = append(blocks, block{kind: "templates"})
|
||||
curBlock = &blocks[len(blocks)-1]
|
||||
}
|
||||
curBlock.templates = append(curBlock.templates, primaryEntry{
|
||||
name: tName,
|
||||
url: urlPath,
|
||||
xmlStr: serialized,
|
||||
inheritFrom: tInherit,
|
||||
})
|
||||
templateCount++
|
||||
}
|
||||
// Elements without t-name and without extension mode are skipped.
|
||||
}
|
||||
}
|
||||
|
||||
// Generate JS registerTemplate / registerTemplateExtension calls.
|
||||
var content []string
|
||||
names := map[string]bool{}
|
||||
primaryParents := map[string]bool{}
|
||||
extensionParents := map[string]bool{}
|
||||
|
||||
for i := range blocks {
|
||||
blk := &blocks[i]
|
||||
if blk.kind == "templates" {
|
||||
for _, t := range blk.templates {
|
||||
if t.inheritFrom != "" {
|
||||
primaryParents[t.inheritFrom] = true
|
||||
}
|
||||
names[t.name] = true
|
||||
escaped := escapeForJSTemplateLiteral(t.xmlStr)
|
||||
content = append(content, fmt.Sprintf("registerTemplate(%q, `%s`, `%s`);",
|
||||
t.name, t.url, escaped))
|
||||
}
|
||||
} else {
|
||||
for _, ext := range blk.extensions {
|
||||
extensionParents[ext.inheritFrom] = true
|
||||
escaped := escapeForJSTemplateLiteral(ext.xmlStr)
|
||||
content = append(content, fmt.Sprintf("registerTemplateExtension(%q, `%s`, `%s`);",
|
||||
ext.inheritFrom, ext.url, escaped))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for missing parent templates (diagnostic aid).
|
||||
var missingPrimary []string
|
||||
for p := range primaryParents {
|
||||
if !names[p] {
|
||||
missingPrimary = append(missingPrimary, p)
|
||||
}
|
||||
}
|
||||
if len(missingPrimary) > 0 {
|
||||
content = append(content, fmt.Sprintf("checkPrimaryTemplateParents(%s);", jsonStringArray(missingPrimary)))
|
||||
}
|
||||
|
||||
var missingExtension []string
|
||||
for p := range extensionParents {
|
||||
if !names[p] {
|
||||
missingExtension = append(missingExtension, p)
|
||||
}
|
||||
}
|
||||
if len(missingExtension) > 0 {
|
||||
content = append(content, fmt.Sprintf("console.error(\"Missing (extension) parent templates: %s\");",
|
||||
strings.Join(missingExtension, ", ")))
|
||||
}
|
||||
|
||||
// Wrap in odoo.define module.
|
||||
var buf strings.Builder
|
||||
buf.WriteString("odoo.define(\"@web/bundle_xml\", [\"@web/core/templates\"], function(require) {\n")
|
||||
buf.WriteString(" \"use strict\";\n")
|
||||
buf.WriteString(" const { checkPrimaryTemplateParents, registerTemplate, registerTemplateExtension } = require(\"@web/core/templates\");\n")
|
||||
buf.WriteString("\n")
|
||||
for _, line := range content {
|
||||
buf.WriteString(line)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
buf.WriteString("});\n")
|
||||
|
||||
log.Printf("templates: %d templates compiled, %d errors", templateCount, errorCount)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// readFileFromFrontend reads a file from the frontend assets directory.
|
||||
func readFileFromFrontend(frontendDir string, urlPath string) string {
|
||||
if frontendDir == "" {
|
||||
return ""
|
||||
}
|
||||
rel := strings.TrimPrefix(urlPath, "/")
|
||||
fullPath := filepath.Join(frontendDir, rel)
|
||||
data, err := os.ReadFile(fullPath)
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// --- XML parsing ---------------------------------------------------------
|
||||
//
|
||||
// Strategy: we use encoding/xml.Decoder only to identify where each direct
|
||||
// child of the root element starts and ends (via InputOffset). Then we
|
||||
// extract the raw substring from the original file content. This preserves
|
||||
// whitespace, attribute order, namespace prefixes, and all other details
|
||||
// exactly as they appear in the source file -- critical for OWL template
|
||||
// fidelity.
|
||||
//
|
||||
// After extraction we strip XML comments from the raw snippet (matching
|
||||
// Python lxml's remove_comments=True) and inject the xml:space="preserve"
|
||||
// attribute into the root element of the snippet (matching Python lxml's
|
||||
// etree.tostring behavior after element.set(xml:space, "preserve")).
|
||||
|
||||
// xmlElement represents a parsed direct child of the XML root.
|
||||
type xmlElement struct {
|
||||
raw string // the serialized XML string of the element
|
||||
attrs map[string]string // element attributes (for t-name, t-inherit, etc.)
|
||||
}
|
||||
|
||||
func elemAttr(e xmlElement, name string) string {
|
||||
return e.attrs[name]
|
||||
}
|
||||
|
||||
// parseXMLTemplateFile extracts the direct children of the root element.
|
||||
// It returns each child as an xmlElement with its raw XML and attributes.
|
||||
func parseXMLTemplateFile(fileContent string) ([]xmlElement, error) {
|
||||
decoder := xml.NewDecoder(strings.NewReader(fileContent))
|
||||
// Odoo templates use custom attribute names (t-name, t-if, etc.) that
|
||||
// are valid XML but may trigger namespace warnings. Use non-strict
|
||||
// mode and provide an entity map for common HTML entities.
|
||||
decoder.Strict = false
|
||||
decoder.Entity = defaultXMLEntities()
|
||||
|
||||
var elements []xmlElement
|
||||
depth := 0 // 0 = outside root, 1 = inside root, 2+ = inside child
|
||||
|
||||
// childStartOffset: byte offset just before the '<' of the child's
|
||||
// opening tag.
|
||||
childStartOffset := int64(0)
|
||||
childDepth := 0
|
||||
inChild := false
|
||||
var childAttrs map[string]string
|
||||
|
||||
for {
|
||||
// Capture offset before consuming token. InputOffset reports the
|
||||
// byte position of the NEXT byte the decoder will read, so we
|
||||
// record it before calling Token() to get the start of each token.
|
||||
offset := decoder.InputOffset()
|
||||
tok, err := decoder.Token()
|
||||
if err != nil {
|
||||
break // EOF or error
|
||||
}
|
||||
|
||||
switch t := tok.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if depth == 2 && !inChild {
|
||||
// Start of a direct child of root
|
||||
inChild = true
|
||||
childStartOffset = offset
|
||||
childDepth = 1
|
||||
childAttrs = make(map[string]string)
|
||||
for _, a := range t.Attr {
|
||||
key := a.Name.Local
|
||||
if a.Name.Space != "" {
|
||||
key = a.Name.Space + ":" + key
|
||||
}
|
||||
childAttrs[key] = a.Value
|
||||
}
|
||||
} else if inChild {
|
||||
childDepth++
|
||||
}
|
||||
case xml.EndElement:
|
||||
if inChild {
|
||||
childDepth--
|
||||
if childDepth == 0 {
|
||||
// End of direct child -- extract raw XML.
|
||||
endOffset := decoder.InputOffset()
|
||||
raw := fileContent[childStartOffset:endOffset]
|
||||
|
||||
// Strip XML comments (<!-- ... -->)
|
||||
raw = stripXMLComments(raw)
|
||||
|
||||
// Inject xml:space="preserve" into root element
|
||||
raw = injectXmlSpacePreserve(raw)
|
||||
|
||||
elements = append(elements, xmlElement{
|
||||
raw: raw,
|
||||
attrs: childAttrs,
|
||||
})
|
||||
inChild = false
|
||||
childAttrs = nil
|
||||
}
|
||||
}
|
||||
depth--
|
||||
}
|
||||
}
|
||||
|
||||
return elements, nil
|
||||
}
|
||||
|
||||
// stripXMLComments removes all XML comments from a string.
|
||||
func stripXMLComments(s string) string {
|
||||
for {
|
||||
start := strings.Index(s, "<!--")
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(s[start:], "-->")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
s = s[:start] + s[start+end+3:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// injectXmlSpacePreserve adds xml:space="preserve" to the first opening
|
||||
// tag in the string, unless it already has that attribute.
|
||||
func injectXmlSpacePreserve(s string) string {
|
||||
if strings.Contains(s, `xml:space=`) {
|
||||
return s
|
||||
}
|
||||
// Find the end of the first opening tag (the first '>' that isn't
|
||||
// inside an attribute value).
|
||||
idx := findFirstTagClose(s)
|
||||
if idx < 0 {
|
||||
return s
|
||||
}
|
||||
return s[:idx] + ` xml:space="preserve"` + s[idx:]
|
||||
}
|
||||
|
||||
// findFirstTagClose returns the index of the '>' that closes the first
|
||||
// start tag in the string, correctly skipping over '>' characters inside
|
||||
// attribute values.
|
||||
func findFirstTagClose(s string) int {
|
||||
inAttr := false
|
||||
attrQuote := byte(0)
|
||||
started := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if !started {
|
||||
if c == '<' {
|
||||
started = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if inAttr {
|
||||
if c == attrQuote {
|
||||
inAttr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == '"' || c == '\'' {
|
||||
inAttr = true
|
||||
attrQuote = c
|
||||
continue
|
||||
}
|
||||
if c == '>' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// defaultXMLEntities returns a map of common HTML entities that may appear
|
||||
// in Odoo templates (encoding/xml only knows the 5 XML built-in entities).
|
||||
func defaultXMLEntities() map[string]string {
|
||||
return map[string]string{
|
||||
"nbsp": "\u00A0",
|
||||
"lt": "<",
|
||||
"gt": ">",
|
||||
"amp": "&",
|
||||
"quot": "\"",
|
||||
"apos": "'",
|
||||
}
|
||||
}
|
||||
|
||||
// --- JS escaping ---------------------------------------------------------
|
||||
|
||||
// escapeForJSTemplateLiteral escapes a string for use inside a JS template
|
||||
// literal (backtick-delimited string).
|
||||
func escapeForJSTemplateLiteral(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "`", "\\`")
|
||||
s = strings.ReplaceAll(s, "${", "\\${")
|
||||
return s
|
||||
}
|
||||
|
||||
// --- Helpers -------------------------------------------------------------
|
||||
|
||||
// serializeElement returns the raw XML string of an xmlElement.
|
||||
func serializeElement(e xmlElement) string {
|
||||
return e.raw
|
||||
}
|
||||
|
||||
// jsonStringArray returns a JSON-encoded array of strings (simple, no
|
||||
// external dependency needed).
|
||||
func jsonStringArray(items []string) string {
|
||||
var parts []string
|
||||
for _, s := range items {
|
||||
escaped := strings.ReplaceAll(s, "\\", "\\\\")
|
||||
escaped = strings.ReplaceAll(escaped, "\"", "\\\"")
|
||||
parts = append(parts, "\""+escaped+"\"")
|
||||
}
|
||||
return "[" + strings.Join(parts, ", ") + "]"
|
||||
}
|
||||
Reference in New Issue
Block a user