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 ). 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 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 } // For self-closing tags like , insert before the '/' if idx > 0 && s[idx-1] == '/' { return s[:idx-1] + ` xml:space="preserve"` + s[idx-1:] } 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, ", ") + "]" }