forked from Botanical/BotanJS
365 lines
8.4 KiB
Go
365 lines
8.4 KiB
Go
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
|
|
}
|