// Package server — JS module transpiler. // Mirrors: odoo/tools/js_transpiler.py // // Converts ES module syntax (import/export) to odoo.define() format: // // import { X } from "@web/foo" --> const { X } = require("@web/foo") // export class Foo { ... } --> const Foo = __exports.Foo = class Foo { ... } // // Wrapped in: // // odoo.define("@web/core/foo", ["@web/foo"], function(require) { // "use strict"; let __exports = {}; // ... // return __exports; // }); package server import ( "fmt" "regexp" "strings" ) // Compiled regex patterns for import/export matching. // Mirrors: odoo/tools/js_transpiler.py URL_RE, IMPORT_RE, etc. var ( // Import patterns — (?m)^ ensures we only match at line start (not inside comments) reNamedImport = regexp.MustCompile(`(?m)^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`) reDefaultImport = regexp.MustCompile(`(?m)^\s*import\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`) reNamespaceImport = regexp.MustCompile(`(?m)^\s*import\s*\*\s*as\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`) reSideEffectImport = regexp.MustCompile(`(?m)^\s*import\s*["']([^"']+)["']\s*;?`) // Export patterns reExportClass = regexp.MustCompile(`export\s+class\s+(\w+)`) reExportFunction = regexp.MustCompile(`export\s+(async\s+)?function\s+(\w+)`) reExportConst = regexp.MustCompile(`export\s+const\s+(\w+)`) reExportLet = regexp.MustCompile(`export\s+let\s+(\w+)`) reExportDefault = regexp.MustCompile(`export\s+default\s+`) reExportNamedFrom = regexp.MustCompile(`export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`) reExportNamed = regexp.MustCompile(`export\s*\{([^}]*)\}\s*;?`) reExportStar = regexp.MustCompile(`export\s*\*\s*from\s*["']([^"']+)["']\s*;?`) // Block comment removal reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`) // Detection patterns reHasImport = regexp.MustCompile(`(?m)^\s*import\s`) reHasExport = regexp.MustCompile(`(?m)^\s*export\s`) reOdooModuleTag = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module`) reOdooModuleIgnore = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module\s+ignore`) ) // TranspileJS converts an ES module JS file to odoo.define() format. // Mirrors: odoo/tools/js_transpiler.py transpile_javascript() // // urlPath is the URL-style path, e.g. "/web/static/src/core/foo.js". // content is the raw JS source code. // Returns the transpiled source, or the original content if the file is not // an ES module. func TranspileJS(urlPath, content string) string { if !IsOdooModule(urlPath, content) { return content } moduleName := URLToModuleName(urlPath) // Extract imports and build dependency list deps, requireLines, cleanContent := extractImports(moduleName, content) // Transform exports cleanContent, exportedFuncs := transformExports(cleanContent) // Wrap in odoo.define return wrapWithOdooDefine(moduleName, deps, requireLines, cleanContent, exportedFuncs) } // URLToModuleName converts a URL path to an Odoo module name. // Mirrors: odoo/tools/js_transpiler.py url_to_module_name() // // Examples: // // /web/static/src/core/foo.js -> @web/core/foo // /web/static/src/env.js -> @web/env // /web/static/lib/luxon/luxon.js -> @web/../lib/luxon/luxon // /stock/static/src/widgets/foo.js -> @stock/widgets/foo func URLToModuleName(url string) string { // Remove leading slash path := strings.TrimPrefix(url, "/") // Remove .js extension path = strings.TrimSuffix(path, ".js") // Split into addon name and the rest parts := strings.SplitN(path, "/", 2) if len(parts) < 2 { return "@" + path } addonName := parts[0] rest := parts[1] // Remove "static/src/" prefix from the rest if strings.HasPrefix(rest, "static/src/") { rest = strings.TrimPrefix(rest, "static/src/") } else if strings.HasPrefix(rest, "static/") { // For lib files: static/lib/foo -> ../lib/foo rest = "../" + strings.TrimPrefix(rest, "static/") } return "@" + addonName + "/" + rest } // resolveRelativeImport converts relative import paths to absolute module names. // E.g., if current module is "@web/core/browser/feature_detection" and dep is "./browser", // it resolves to "@web/core/browser/browser". // "../utils/hooks" from "@web/core/browser/feature_detection" → "@web/core/utils/hooks" func resolveRelativeImport(currentModule, dep string) string { if !strings.HasPrefix(dep, "./") && !strings.HasPrefix(dep, "../") { return dep // Already absolute } // Split current module into parts: @web/core/browser/feature_detection → [core, browser] // (remove the @addon/ prefix and the filename) parts := strings.Split(currentModule, "/") if len(parts) < 2 { return dep } // Get the directory of the current module (drop the last segment = filename) dir := parts[:len(parts)-1] // Resolve the relative path relParts := strings.Split(dep, "/") for _, p := range relParts { if p == "." { continue } else if p == ".." { if len(dir) > 1 { dir = dir[:len(dir)-1] } } else { dir = append(dir, p) } } return strings.Join(dir, "/") } // IsOdooModule determines whether a JS file should be transpiled. // Mirrors: odoo/tools/js_transpiler.py is_odoo_module() // // Returns true if the file contains ES module syntax (import/export) or // the @odoo-module tag (without "ignore"). func IsOdooModule(url, content string) bool { // Must be a JS file if !strings.HasSuffix(url, ".js") { return false } // Explicit ignore directive if reOdooModuleIgnore.MatchString(content) { return false } // Explicit @odoo-module tag if reOdooModuleTag.MatchString(content) { return true } // Has import or export statements if reHasImport.MatchString(content) || reHasExport.MatchString(content) { return true } return false } // extractImports finds all import statements in the content, returns: // - deps: list of dependency module names (for the odoo.define deps array) // - requireLines: list of "const ... = require(...)" lines // - cleanContent: content with import statements removed func extractImports(moduleName, content string) (deps []string, requireLines []string, cleanContent string) { depSet := make(map[string]bool) var depOrder []string resolve := func(dep string) string { return resolveRelativeImport(moduleName, dep) } addDep := func(dep string) { dep = resolve(dep) if !depSet[dep] { depSet[dep] = true depOrder = append(depOrder, dep) } } cleanContent = content // Remove @odoo-module tag line (not needed in output) cleanContent = reOdooModuleTag.ReplaceAllString(cleanContent, "") // Don't strip block comments (it breaks string literals containing /*). // Instead, the import regexes below only match at positions that are // clearly actual code, not inside comments. Since import/export statements // in ES modules must appear at the top level (before any function body), // they'll always be at the beginning of a line. The regexes already handle // this correctly for most cases. The one edge case (import inside JSDoc) // is handled by checking the matched line doesn't start with * or //. // Named imports: import { X, Y as Z } from "dep" cleanContent = reNamedImport.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reNamedImport.FindStringSubmatch(match) if len(m) < 3 { return match } names := m[1] dep := m[2] addDep(dep) // Parse the import specifiers, handle "as" aliases specifiers := parseImportSpecifiers(names) if len(specifiers) == 0 { return "" } // Build destructuring: const { X, Y: Z } = require("dep") var parts []string for _, s := range specifiers { if s.alias != "" { parts = append(parts, s.name+": "+s.alias) } else { parts = append(parts, s.name) } } line := "const { " + strings.Join(parts, ", ") + " } = require(\"" + resolve(dep) + "\");" requireLines = append(requireLines, line) return "" }) // Namespace imports: import * as X from "dep" cleanContent = reNamespaceImport.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reNamespaceImport.FindStringSubmatch(match) if len(m) < 3 { return match } name := m[1] dep := m[2] addDep(dep) line := "const " + name + " = require(\"" + dep + "\");" requireLines = append(requireLines, line) return "" }) // Default imports: import X from "dep" cleanContent = reDefaultImport.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reDefaultImport.FindStringSubmatch(match) if len(m) < 3 { return match } name := m[1] dep := m[2] addDep(dep) // Default import uses Symbol.for("default") line := "const " + name + " = require(\"" + dep + "\")[Symbol.for(\"default\")];" requireLines = append(requireLines, line) return "" }) // Side-effect imports: import "dep" cleanContent = reSideEffectImport.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reSideEffectImport.FindStringSubmatch(match) if len(m) < 2 { return match } dep := m[1] addDep(dep) line := "require(\"" + dep + "\");" requireLines = append(requireLines, line) return "" }) // export { X, Y } from "dep" — named re-export: import dep + export names cleanContent = reExportNamedFrom.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reExportNamedFrom.FindStringSubmatch(match) if len(m) >= 3 { names := m[1] dep := resolve(m[2]) addDep(m[2]) // Named re-export: export { X } from "dep" // Import the dep (using a temp var to avoid redeclaration with existing imports) // then assign to __exports specifiers := parseExportSpecifiers(names) var parts []string tmpVar := fmt.Sprintf("_reexport_%d", len(deps)) parts = append(parts, fmt.Sprintf("var %s = require(\"%s\");", tmpVar, dep)) for _, s := range specifiers { exported := s.name if s.alias != "" { exported = s.alias } parts = append(parts, fmt.Sprintf("__exports.%s = %s.%s;", exported, tmpVar, s.name)) } return strings.Join(parts, " ") } return match }) // export * from "dep" — treat as import dependency cleanContent = reExportStar.ReplaceAllStringFunc(cleanContent, func(match string) string { m := reExportStar.FindStringSubmatch(match) if len(m) >= 2 { addDep(m[1]) } return match // keep the export * line — transformExports will handle it }) deps = depOrder return } // importSpecifier holds a single import specifier, e.g. "X" or "X as Y". type importSpecifier struct { name string // exported name alias string // local alias (empty if same as name) } // parseImportSpecifiers parses the inside of { ... } in an import statement. // E.g. "X, Y as Z, W" -> [{X, ""}, {Y, "Z"}, {W, ""}] func parseImportSpecifiers(raw string) []importSpecifier { var result []importSpecifier for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } fields := strings.Fields(part) switch len(fields) { case 1: result = append(result, importSpecifier{name: fields[0]}) case 3: // "X as Y" if fields[1] == "as" { result = append(result, importSpecifier{name: fields[0], alias: fields[2]}) } } } return result } // transformExports converts export statements to __exports assignments. // Returns the transformed content and a list of exported function names // (these need deferred __exports assignment to preserve hoisting). // Mirrors: odoo/tools/js_transpiler.py (various export transformers) func transformExports(content string) (string, []string) { // export class Foo { ... } -> const Foo = __exports.Foo = class Foo { ... } content = reExportClass.ReplaceAllStringFunc(content, func(match string) string { m := reExportClass.FindStringSubmatch(match) if len(m) < 2 { return match } name := m[1] return "const " + name + " = __exports." + name + " = class " + name }) // export [async] function foo(...) { ... } // Must preserve function declaration hoisting (not var/const) because some // Odoo modules reference the function before its declaration line // (e.g., rpc.setCache assigned before "export function rpc"). // Strategy: keep as hoisted function declaration, collect names, append // __exports assignments at the end. var exportedFuncNames []string content = reExportFunction.ReplaceAllStringFunc(content, func(match string) string { m := reExportFunction.FindStringSubmatch(match) if len(m) < 3 { return match } async := m[1] // "async " or "" name := m[2] exportedFuncNames = append(exportedFuncNames, name) return async + "function " + name }) // export const foo = ... -> const foo = __exports.foo = ... // (replaces just the "export const foo" part, the rest of the line stays) content = reExportConst.ReplaceAllStringFunc(content, func(match string) string { m := reExportConst.FindStringSubmatch(match) if len(m) < 2 { return match } name := m[1] return "const " + name + " = __exports." + name }) // export let foo = ... -> let foo = __exports.foo = ... content = reExportLet.ReplaceAllStringFunc(content, func(match string) string { m := reExportLet.FindStringSubmatch(match) if len(m) < 2 { return match } name := m[1] return "let " + name + " = __exports." + name }) // export { X, Y, Z } -> Object.assign(__exports, { X, Y, Z }); content = reExportNamed.ReplaceAllStringFunc(content, func(match string) string { m := reExportNamed.FindStringSubmatch(match) if len(m) < 2 { return match } names := m[1] // Parse individual names, handle "X as Y" aliases specifiers := parseExportSpecifiers(names) if len(specifiers) == 0 { return "" } var assignments []string for _, s := range specifiers { exportedName := s.name if s.alias != "" { exportedName = s.alias } assignments = append(assignments, "__exports."+exportedName+" = "+s.name+";") } return strings.Join(assignments, " ") }) // export * from "dep" -> Object.assign(__exports, require("dep")) // Also add the dep to the dependency list (handled in extractImports) content = reExportStar.ReplaceAllStringFunc(content, func(match string) string { m := reExportStar.FindStringSubmatch(match) if len(m) < 2 { return match } dep := m[1] return fmt.Sprintf(`Object.assign(__exports, require("%s"))`, dep) }) // export default X -> __exports[Symbol.for("default")] = X // Must come after other export patterns to avoid double-matching content = reExportDefault.ReplaceAllString(content, `__exports[Symbol.for("default")] = `) return content, exportedFuncNames } // exportSpecifier holds a single export specifier from "export { X, Y as Z }". type exportSpecifier struct { name string // local name alias string // exported name (empty if same as name) } // parseExportSpecifiers parses the inside of { ... } in an export statement. // E.g. "X, Y as Z" -> [{X, ""}, {Y, "Z"}] func parseExportSpecifiers(raw string) []exportSpecifier { var result []exportSpecifier for _, part := range strings.Split(raw, ",") { part = strings.TrimSpace(part) if part == "" { continue } fields := strings.Fields(part) switch len(fields) { case 1: result = append(result, exportSpecifier{name: fields[0]}) case 3: // "X as Y" if fields[1] == "as" { result = append(result, exportSpecifier{name: fields[0], alias: fields[2]}) } } } return result } // wrapWithOdooDefine wraps the transpiled content in an odoo.define() call. // Mirrors: odoo/tools/js_transpiler.py wrap_with_odoo_define() func wrapWithOdooDefine(moduleName string, deps []string, requireLines []string, content string, exportedFuncs []string) string { var b strings.Builder // Module definition header b.WriteString("odoo.define(\"") b.WriteString(moduleName) b.WriteString("\", [") // Dependencies array for i, dep := range deps { if i > 0 { b.WriteString(", ") } b.WriteString("\"") b.WriteString(dep) b.WriteString("\"") } b.WriteString("], function(require) {\n") b.WriteString("\"use strict\";\n") b.WriteString("let __exports = {};\n") // Require statements for _, line := range requireLines { b.WriteString(line) b.WriteString("\n") } // Original content (trimmed of leading/trailing whitespace) trimmed := strings.TrimSpace(content) if trimmed != "" { b.WriteString(trimmed) b.WriteString("\n") } // Deferred __exports assignments for hoisted function declarations for _, name := range exportedFuncs { b.WriteString("__exports." + name + " = " + name + ";\n") } // Return exports b.WriteString("return __exports;\n") b.WriteString("});\n") return b.String() } // stripJSDocImports removes import/export statements that appear inside JSDoc // block comments. Instead of stripping all /* ... */ (which breaks string literals // containing /*), we only neutralize import/export lines that are preceded by // a JSDoc comment start (/**) on a prior line. We detect this by checking if // the line is inside a comment block. func stripJSDocImports(content string) string { lines := strings.Split(content, "\n") inComment := false var result []string for _, line := range lines { trimmed := strings.TrimSpace(line) // Track block comment state if strings.HasPrefix(trimmed, "/*") { inComment = true } if inComment { // Neutralize import/export statements inside comments // by replacing 'import' with '_import' and 'export' with '_export' if strings.Contains(trimmed, "import ") || strings.Contains(trimmed, "export ") { line = strings.Replace(line, "import ", "_import_in_comment ", 1) line = strings.Replace(line, "export ", "_export_in_comment ", 1) } } if strings.Contains(trimmed, "*/") { inComment = false } result = append(result, line) } return strings.Join(result, "\n") }