package resolver import ( "bytes" "compress/zlib" "crypto/md5" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "os" "path/filepath" "regexp" "sort" "strings" "sync" "github.com/tgckpg/resolver-go/internal/classmap" "github.com/tgckpg/resolver-go/internal/closure" ) type OutputMode string const ( ModeJS OutputMode = "js" ModeCSS OutputMode = "css" ) type Result struct { Mode OutputMode Request []string JSFiles []classmap.Resource CSSFiles []classmap.Resource Hash string Content []byte ContentType string } type Resolver struct { Root string Map *classmap.Map externMu sync.RWMutex externCache map[string]closure.SourceInput } func New(root string, m *classmap.Map) (*Resolver, error) { root, err := filepath.Abs(root) if err != nil { return nil, err } return &Resolver{ Root: root, Map: m, externCache: make(map[string]closure.SourceInput), }, nil } func (r *Resolver) ResolveRequest(code string, mode OutputMode, excludes []string) (Result, error) { apis, err := DecodeRequest(code) if err != nil { return Result{}, err } var includeSyms []classmap.Symbol var excludeSyms []classmap.Symbol for _, api := range apis { if api == "" { continue } neg := strings.HasPrefix(api, "-") api = strings.TrimPrefix(api, "-") syms, err := r.ResolveSymbol(api) if err != nil { return Result{}, err } if neg { excludeSyms = append(excludeSyms, syms...) } else { includeSyms = append(includeSyms, syms...) } } for _, ex := range excludes { syms, err := r.ResolveSymbol(ex) if err != nil { return Result{}, err } excludeSyms = append(excludeSyms, syms...) } jsFiles := r.resourcesFor(includeSyms) jsExcludes := r.resourcesFor(excludeSyms) jsFiles = subtractResources(jsFiles, jsExcludes) res := Result{Mode: mode, Request: apis, JSFiles: jsFiles} switch mode { case ModeJS: content, hash, err := r.MergeJS(jsFiles) res.Hash, res.Content, res.ContentType = hash, content, "application/javascript" return res, err case ModeCSS: cssIn := r.cssResources(jsFiles) cssEx := r.cssResources(jsExcludes) cssFiles := subtractResources(cssIn, cssEx) content, hash, err := r.MergeCSS(cssFiles) res.CSSFiles, res.Hash, res.Content, res.ContentType = cssFiles, hash, content, "text/css" return res, err default: return Result{}, fmt.Errorf("invalid mode: %s", mode) } } func (r *Resolver) ResolveSymbol(name string) ([]classmap.Symbol, error) { seen := map[string]bool{} var out []classmap.Symbol if err := r.resolveImport(name, seen, &out); err != nil { return nil, err } return out, nil } func (r *Resolver) resolveImport(name string, seen map[string]bool, out *[]classmap.Symbol) error { if strings.HasSuffix(name, ".*") { return r.resolveWildcard(name, seen, out) } return r.resolveOne(name, seen, out) } func (r *Resolver) resolveWildcard(name string, seen map[string]bool, out *[]classmap.Symbol) error { prefix := strings.TrimSuffix(name, "*") namespace := strings.TrimSuffix(prefix, ".") var keys []string for k, s := range r.Map.Symbols { if strings.HasPrefix(k, prefix) && s.Kind == classmap.KindClass && k != namespace { keys = append(keys, k) } } if len(keys) == 0 { return fmt.Errorf("namespace does not exist or contains no classes: %s", name) } sort.Strings(keys) for _, k := range keys { if err := r.resolveOne(k, seen, out); err != nil { return err } } return nil } func (r *Resolver) resolveOne(name string, seen map[string]bool, out *[]classmap.Symbol) error { if seen[name] { return nil } sym, ok := r.Map.Symbols[name] if !ok { return fmt.Errorf("no such class: %s", name) } seen[name] = true imports := sym.Imports // Old resolver uses parent imports when the found node is prop/method. if sym.Kind != classmap.KindClass && sym.Parent != "" { if p, ok := r.Map.Symbols[sym.Parent]; ok { imports = p.Imports } } for _, imp := range imports { if err := r.resolveImport(imp, seen, out); err != nil { return err } } *out = append(*out, sym) return nil } func (r *Resolver) resourcesFor(syms []classmap.Symbol) []classmap.Resource { var out []classmap.Resource for _, s := range syms { res, ok := r.resourceFor(s) if ok { out = appendResource(out, res) } } return out } func (r *Resolver) resourceFor(s classmap.Symbol) (classmap.Resource, bool) { if s.Resource.Src != "" { return s.Resource, true } for p := s.Parent; p != ""; { ps, ok := r.Map.Symbols[p] if !ok { break } if ps.Resource.Src != "" { return ps.Resource, true } p = ps.Parent } return classmap.Resource{}, false } func (r *Resolver) cssResources(jsFiles []classmap.Resource) []classmap.Resource { var out []classmap.Resource for _, res := range jsFiles { if res.CSSHash != "1" { out = appendResource(out, res) } parts := strings.Split(res.Src, "/") for i := 1; i < len(parts); i++ { key := strings.Join(parts[:len(parts)-i], "/") + "/_this.js" def, ok := r.Map.Files[key] if ok && def.CSSHash != "1" { out = appendResource(out, def) } } } sort.SliceStable(out, func(i, j int) bool { return out[i].Src < out[j].Src }) return out } func (r *Resolver) MergeJS(files []classmap.Resource) ([]byte, string, error) { var b bytes.Buffer head, _ := os.ReadFile(filepath.Join(r.Root, "_this.js")) b.Write(head) for _, f := range files { pathName := strings.TrimSuffix(f.Src, ".js") pathName = strings.ReplaceAll(pathName, "/", ".") pathName = strings.ReplaceAll(pathName, "._this", "") b.WriteString(";BotanJS.define(\"") b.WriteString(pathName) b.WriteString("\");") src, err := os.ReadFile(filepath.Join(r.Root, filepath.FromSlash(f.Src))) if err != nil { return nil, "", err } b.Write(src) } wrapped := append([]byte("(function(){"), b.Bytes()...) wrapped = append(wrapped, []byte("})();")...) return wrapped, hashList(files, "js"), nil } func (r *Resolver) MergeCSS(files []classmap.Resource) ([]byte, string, error) { var b bytes.Buffer b.WriteString("/* @ */") head, _ := os.ReadFile(filepath.Join(r.Root, "_this.css")) b.Write(head) for _, f := range files { cssPath := strings.TrimSuffix(f.Src, ".js") + ".css" src, err := os.ReadFile(filepath.Join(r.Root, filepath.FromSlash(cssPath))) if err != nil { if errors.Is(err, os.ErrNotExist) { continue } return nil, "", err } b.Write(src) } return b.Bytes(), hashList(files, "css"), nil } func (r *Resolver) GetExterns(files []string) ([]closure.SourceInput, error) { sources := make([]closure.SourceInput, 0, len(files)) for _, f := range files { src, ok, err := r.getExtern(f) if err != nil { return nil, err } if !ok { continue } sources = append(sources, src) } return sources, nil } func (r *Resolver) getExtern(f string) (closure.SourceInput, bool, error) { // Fast path: read cache. r.externMu.RLock() src, ok := r.externCache[f] r.externMu.RUnlock() if ok { return src, true, nil } // Slow path: read file. b, err := os.ReadFile(filepath.Join(r.Root, filepath.FromSlash(f))) if err != nil { if errors.Is(err, os.ErrNotExist) { return closure.SourceInput{}, false, nil } return closure.SourceInput{}, false, err } src = closure.SourceInput{ Name: f, Source: string(b), } // Store cache. r.externMu.Lock() r.externCache[f] = src r.externMu.Unlock() return src, true, nil } func DecodeRequest(code string) ([]string, error) { sep := "/" decoded := code if raw, err := base64.StdEncoding.DecodeString(code); err == nil { zr, err := zlib.NewReader(bytes.NewReader(raw)) if err == nil { buf, readErr := io.ReadAll(zr) _ = zr.Close() if readErr != nil { return nil, readErr } decoded = string(buf) sep = "," } } cleaner := regexp.MustCompile(`[^A-Za-z0-9.\*\-_/ ,]`) decoded = cleaner.ReplaceAllString(decoded, "") var fields []string for _, p := range strings.Split(decoded, sep) { p = strings.TrimSpace(p) if p != "" { fields = append(fields, p) } } return fields, nil } func appendResource(out []classmap.Resource, res classmap.Resource) []classmap.Resource { for _, x := range out { if x.Src == res.Src { return out } } return append(out, res) } func subtractResources(in, ex []classmap.Resource) []classmap.Resource { drop := map[string]bool{} for _, x := range ex { drop[x.Src] = true } var out []classmap.Resource for _, x := range in { if !drop[x.Src] { out = append(out, x) } } return out } func hashList(files []classmap.Resource, mode string) string { var parts []string for _, f := range files { if mode == "css" { parts = append(parts, f.CSSHash) } else { parts = append(parts, f.JSHash) } } sum := md5.Sum([]byte(strings.Join(parts, "|"))) return hex.EncodeToString(sum[:]) + "." + mode }