mirror of
https://github.com/tiennm99/hugoThemesSiteBuilder.git
synced 2026-10-11 12:19:02 +00:00
431 lines
9.6 KiB
Go
431 lines
9.6 KiB
Go
package client
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"io/ioutil"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
toml "github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
const (
|
|
modPath = "github.com/gohugoio/hugoThemesSiteBuilder/cmd/hugothemesitebuilder/build"
|
|
cacheDir = "cache"
|
|
)
|
|
|
|
func New(logWriter io.Writer, outDir string) (*Client, error) {
|
|
numWorkers := runtime.NumCPU()
|
|
if numWorkers > 8 {
|
|
numWorkers = 8
|
|
}
|
|
return &Client{logWriter: logWriter, outDir: outDir}, nil
|
|
}
|
|
|
|
type Client struct {
|
|
logWriter io.Writer
|
|
outDir string
|
|
|
|
badHostsInit sync.Once
|
|
badHosts map[string]bool
|
|
}
|
|
|
|
func (c *Client) IsBadURL(s string) bool {
|
|
u, err := url.Parse(s)
|
|
if err != nil {
|
|
return true
|
|
}
|
|
|
|
c.badHostsInit.Do(func() {
|
|
c.badHosts = make(map[string]bool)
|
|
f, err := os.Open(filepath.Join(c.outDir, "badhosts.txt"))
|
|
if err != nil {
|
|
panic(fmt.Sprintf("failed to open badhosts.txt: %s", err))
|
|
}
|
|
defer f.Close()
|
|
scanner := bufio.NewScanner(f)
|
|
counter := 0
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" || strings.HasPrefix(line, "#") {
|
|
continue
|
|
}
|
|
parts := strings.Fields(line)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
c.badHosts[strings.ToLower(parts[1])] = true
|
|
counter++
|
|
|
|
}
|
|
|
|
c.Logf("Loaded %d bad hosts", counter)
|
|
})
|
|
|
|
host := strings.ToLower(u.Host)
|
|
|
|
return c.badHosts[host] || c.badHosts[strings.TrimPrefix(host, "www.")]
|
|
}
|
|
|
|
func (c *Client) GetHugoModulesMap(config string) (ModulesMap, error) {
|
|
defer c.TimeTrack(time.Now(), "Got Hugo Modules")
|
|
b := &bytes.Buffer{}
|
|
if err := c.runHugo(b, "--config", config, "config", "mounts"); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
mmap := make(ModulesMap)
|
|
dec := json.NewDecoder(b)
|
|
|
|
c.Logf("Get Hugo Modules, config %q", config)
|
|
|
|
for dec.More() {
|
|
var m Module
|
|
if derr := dec.Decode(&m); derr != nil {
|
|
b, _ := io.ReadAll(dec.Buffered())
|
|
return nil, fmt.Errorf("failed to decode module config: %s\n%s", derr, b)
|
|
}
|
|
|
|
if m.Owner == modPath {
|
|
mmap[m.Path] = m
|
|
}
|
|
}
|
|
|
|
for k, v := range mmap {
|
|
|
|
// Read any theme.toml into .Meta
|
|
filename := filepath.Join(v.Dir, "theme.toml")
|
|
if _, err := os.Stat(filename); err == nil {
|
|
b, err := os.ReadFile(filename)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read %q: %s", filename, err)
|
|
}
|
|
if err := toml.Unmarshal(b, &v.Meta); err != nil {
|
|
c.Logf("warn: failed to parse theme.toml for theme %q: %s", k, err)
|
|
}
|
|
mmap[k] = v
|
|
}
|
|
}
|
|
|
|
return mmap, nil
|
|
}
|
|
|
|
// Logf logs to the configured log writer.
|
|
func (c *Client) Logf(format string, a ...interface{}) {
|
|
fmt.Fprintf(c.logWriter, format+"\n", a...)
|
|
}
|
|
|
|
func (c *Client) InitModule() error {
|
|
return c.RunHugo("mod", "init", modPath)
|
|
}
|
|
|
|
func (c *Client) OutFileExists(name string) bool {
|
|
filename := filepath.Join(c.outDir, name)
|
|
_, err := os.Stat(filename)
|
|
return err == nil
|
|
}
|
|
|
|
func (c *Client) RemoveGoModAndGoSum() {
|
|
goModFilename := filepath.Join(c.outDir, "go.mod")
|
|
goSumFilename := filepath.Join(c.outDir, "go.sum")
|
|
os.Remove(goModFilename)
|
|
os.Remove(goSumFilename)
|
|
}
|
|
|
|
func (c *Client) RunHugo(arg ...string) error {
|
|
return c.runHugo(nil, arg...)
|
|
}
|
|
|
|
// CreateThemesConfig reads themes.txt and creates a config.json
|
|
// suitable for Hugo. Note that we're only using that config to
|
|
// get the full module listing.
|
|
func (c *Client) CreateThemesConfig() error {
|
|
// This looks a little funky, but we want the themes.txt to be
|
|
// easily visible for users to add to in the root of the project.
|
|
f, err := os.Open(filepath.Join(c.outDir, "../../..", "themes.txt"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer f.Close()
|
|
|
|
config := make(map[string]interface{})
|
|
var imports []map[string]interface{}
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if !strings.HasPrefix(line, "#") {
|
|
imports = append(imports, map[string]interface{}{
|
|
"path": line,
|
|
"ignoreImports": true,
|
|
"ignoreConfig": true,
|
|
"noMounts": true,
|
|
})
|
|
}
|
|
}
|
|
|
|
if err := scanner.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
config["module"] = map[string]interface{}{
|
|
"hugoVersion": map[string]interface{}{
|
|
"min": "0.115.0",
|
|
},
|
|
"imports": imports,
|
|
}
|
|
|
|
b, err := json.MarshalIndent(config, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return os.WriteFile(filepath.Join(c.outDir, "config.json"), b, 0o666)
|
|
}
|
|
|
|
func (c *Client) JoinOutPath(elem ...string) string {
|
|
return filepath.Join(append([]string{c.outDir}, elem...)...)
|
|
}
|
|
|
|
func (c *Client) TimeTrack(start time.Time, name string) {
|
|
elapsed := time.Since(start)
|
|
fmt.Fprintf(c.logWriter, "%s in %v ms\n", name, int(1000*elapsed.Seconds()))
|
|
}
|
|
|
|
const gitHubReposCacheFilename = "githubrepos.json"
|
|
|
|
// GetGitHubRepos will first look in the chache folder for GitHub repo
|
|
// information for mods. If not found, it will ask GitHub and then store
|
|
// it in the cache.
|
|
//
|
|
// If you start with an empty cache, you will need to set a GITHUB_TOKEN environment variable.
|
|
func (c *Client) GetGitHubRepos(mods ModulesMap, cleanCache bool) (map[string]GitHubRepo, error) {
|
|
c.Logf("Get GitHub repos")
|
|
defer c.TimeTrack(time.Now(), "Got GitHub repos")
|
|
cacheFilename := filepath.Join(c.outDir, cacheDir, gitHubReposCacheFilename)
|
|
if cleanCache {
|
|
os.Remove(cacheFilename)
|
|
}
|
|
b, err := os.ReadFile(cacheFilename)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return nil, err
|
|
}
|
|
|
|
// Fetch the github repos and store in cache.
|
|
m, err := c.fetchGitHubRepos(mods)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(m) > 0 {
|
|
b, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
CheckErr(os.MkdirAll(filepath.Dir(cacheFilename), 0o777))
|
|
CheckErr(os.WriteFile(cacheFilename, b, 0o666))
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
m := make(map[string]GitHubRepo)
|
|
|
|
if err = json.Unmarshal(b, &m); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
func (c *Client) fetchGitHubRepo(m Module) (GitHubRepo, error) {
|
|
var repo GitHubRepo
|
|
|
|
const githubdotcom = "github.com"
|
|
|
|
if !strings.HasPrefix(m.Path, githubdotcom) {
|
|
return repo, nil
|
|
}
|
|
repoPath := strings.TrimPrefix(m.PathRepo(), githubdotcom)
|
|
apiURL := "https://api.github.com/repos" + repoPath
|
|
|
|
req, err := http.NewRequest("GET", apiURL, nil)
|
|
if err != nil {
|
|
return repo, err
|
|
}
|
|
|
|
err = doGitHubRequest(req, &repo)
|
|
if err != nil {
|
|
return repo, fmt.Errorf("failed to get GitHub repo for %q: %s. Set GITHUB_TOKEN if you get rate limiting errors.", apiURL, err)
|
|
}
|
|
return repo, nil
|
|
}
|
|
|
|
func (c *Client) fetchGitHubRepos(mods ModulesMap) (map[string]GitHubRepo, error) {
|
|
repos := make(map[string]GitHubRepo)
|
|
errCount := 0
|
|
for _, m := range mods {
|
|
repo, err := c.fetchGitHubRepo(m)
|
|
if err != nil {
|
|
if errCount > 5 {
|
|
return repos, err
|
|
}
|
|
errCount++
|
|
c.Logf("warning: %s", err)
|
|
continue
|
|
}
|
|
repos[m.Path] = repo
|
|
}
|
|
|
|
return repos, nil
|
|
}
|
|
|
|
func (c *Client) runHugo(w io.Writer, arg ...string) error {
|
|
env := os.Environ()
|
|
|
|
arg = append(arg, "--quiet")
|
|
|
|
if w == nil {
|
|
w = os.Stdout
|
|
}
|
|
|
|
var errBuf bytes.Buffer
|
|
stderr := io.MultiWriter(os.Stderr, &errBuf)
|
|
|
|
cmd := exec.Command("hugo", arg...)
|
|
cmd.Dir = c.outDir
|
|
cmd.Env = env
|
|
cmd.Stdout = w
|
|
cmd.Stderr = stderr
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return fmt.Errorf("hugo command failed: %s\n%s", err, errBuf.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type GitHubRepo struct {
|
|
ID int `json:"id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
HTMLURL string `json:"html_url"`
|
|
Stars int `json:"stargazers_count"`
|
|
}
|
|
|
|
func (g GitHubRepo) IsZero() bool {
|
|
return g.HTMLURL == ""
|
|
}
|
|
|
|
type Module struct {
|
|
Path string `json:"path"`
|
|
Owner string `json:"owner"`
|
|
Version string `json:"version"`
|
|
Time time.Time `json:"time"`
|
|
Dir string `json:"dir"`
|
|
HugoVersion HugoVersion `json:"hugoVersion"`
|
|
Meta map[string]interface{} `json:"meta"`
|
|
}
|
|
|
|
func (m Module) PathWithoutVersion() string {
|
|
return PathWithoutVersion(m.Path)
|
|
}
|
|
|
|
// PathRepo returns the root path to the repository.
|
|
func (m Module) PathRepo() string {
|
|
slashCount := 0
|
|
p := m.PathWithoutVersion()
|
|
idx := strings.IndexFunc(p, func(r rune) bool {
|
|
if r == '/' {
|
|
slashCount++
|
|
}
|
|
return slashCount > 2
|
|
})
|
|
|
|
if slashCount < 3 {
|
|
return p
|
|
}
|
|
|
|
return p[:idx]
|
|
}
|
|
|
|
// HugoVersion holds Hugo binary version requirements for a module.
|
|
type HugoVersion struct {
|
|
Min string
|
|
Max string
|
|
Extended bool
|
|
}
|
|
|
|
type ModulesMap map[string]Module
|
|
|
|
var pathVersionRe = regexp.MustCompile(`/v\d+$`)
|
|
|
|
func PathWithoutVersion(s string) string {
|
|
return pathVersionRe.ReplaceAllString(s, "")
|
|
}
|
|
|
|
func setEnvVar(vars *[]string, key, value string) {
|
|
for i := range *vars {
|
|
if strings.HasPrefix((*vars)[i], key+"=") {
|
|
(*vars)[i] = key + "=" + value
|
|
return
|
|
}
|
|
}
|
|
// New var.
|
|
*vars = append(*vars, key+"="+value)
|
|
}
|
|
|
|
func setEnvVars(oldVars *[]string, keyValues ...string) {
|
|
for i := 0; i < len(keyValues); i += 2 {
|
|
setEnvVar(oldVars, keyValues[i], keyValues[i+1])
|
|
}
|
|
}
|
|
|
|
func isError(resp *http.Response) bool {
|
|
return resp.StatusCode < 200 || resp.StatusCode > 299
|
|
}
|
|
|
|
func addGitHubToken(req *http.Request) {
|
|
gitHubToken := os.Getenv("GITHUB_TOKEN")
|
|
if gitHubToken != "" {
|
|
req.Header.Add("Authorization", "token "+gitHubToken)
|
|
}
|
|
}
|
|
|
|
func CheckErr(err error) {
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func doGitHubRequest(req *http.Request, v interface{}) error {
|
|
addGitHubToken(req)
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if isError(resp) {
|
|
b, _ := ioutil.ReadAll(resp.Body)
|
|
return fmt.Errorf("GitHub lookup failed: %s", string(b))
|
|
}
|
|
|
|
return json.NewDecoder(resp.Body).Decode(v)
|
|
}
|