Files
goodie/pkg/server/templates.go
Marc 8616def7aa Fix XML template xml:space injection for self-closing tags
The injectXmlSpacePreserve function was inserting the attribute AFTER
the self-closing slash in tags like <t t-name="foo"/>, producing
invalid XML: <t t-name="foo"/ xml:space="preserve">

Now correctly inserts before the slash:
<t t-name="foo" xml:space="preserve"/>

This fixes the "attributes construct error at column 33" that appeared
as a red banner in the Odoo webclient list view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 00:25:29 +02:00

402 lines
11 KiB
Go

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
}
// For self-closing tags like <t t-name="foo"/>, 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, ", ") + "]"
}