penguin/AstroJS

Javascript framework for my blog

resolver-go/internal/resolver/resolver.go

raw ยท 8577 bytes

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 strings.HasSuffix(name, ".*") {
		prefix := strings.TrimSuffix(name, "*")
		var keys []string
		for k, s := range r.Map.Symbols {
			if strings.HasPrefix(k, prefix) && s.Kind == classmap.KindClass && k != strings.TrimSuffix(prefix, ".") {
				keys = append(keys, k)
			}
		}
		if len(keys) == 0 {
			return nil, 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 nil, err
			}
		}
		return out, nil
	}
	if err := r.resolveOne(name, seen, &out); err != nil {
		return nil, err
	}
	return out, 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.resolveOne(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-z.\*\-_/ ,]`)
	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
}