170 lines
4.7 KiB
Go
170 lines
4.7 KiB
Go
package build
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// Assets are the files under assets/: stylesheets, scripts, fonts, the
|
|
// images templates point at. Each is written with a hash of its content in
|
|
// its name (styles.css -> styles.3f9a1c2b07.css), so a browser or a CDN can
|
|
// keep it for a year and a changed file is a new address. Nobody bumps a
|
|
// version by hand, and no edge cache can serve last week's stylesheet.
|
|
type Assets struct {
|
|
urls map[string]string // "styles.css" -> "/styles.3f9a1c2b07.css"
|
|
}
|
|
|
|
// URL returns the published address of an asset, by its path under assets/.
|
|
func (a *Assets) URL(name string) (string, error) {
|
|
name = strings.TrimPrefix(path.Clean("/"+name), "/")
|
|
u, ok := a.urls[name]
|
|
if !ok {
|
|
return "", fmt.Errorf("no asset %q in assets/", name)
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
var cssURLRe = regexp.MustCompile(`url\(\s*(['"]?)([^'")]+)(['"]?)\s*\)`)
|
|
|
|
// writeAssets copies assets/ into out with fingerprinted names. Stylesheets go
|
|
// last, because their url() references are rewritten to the fingerprinted
|
|
// names of the fonts and images they use first.
|
|
func writeAssets(srcDir, out string) (*Assets, error) {
|
|
a := &Assets{urls: map[string]string{}}
|
|
var files []string
|
|
err := filepath.WalkDir(srcDir, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
if strings.HasPrefix(d.Name(), ".") && p != srcDir {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(d.Name(), ".") {
|
|
return nil
|
|
}
|
|
rel, _ := filepath.Rel(srcDir, p)
|
|
files = append(files, filepath.ToSlash(rel))
|
|
return nil
|
|
})
|
|
if os.IsNotExist(err) {
|
|
return a, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(files, func(i, j int) bool {
|
|
return !strings.HasSuffix(files[i], ".css") && strings.HasSuffix(files[j], ".css")
|
|
})
|
|
for _, rel := range files {
|
|
data, err := os.ReadFile(filepath.Join(srcDir, filepath.FromSlash(rel)))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.HasSuffix(rel, ".css") {
|
|
data = []byte(cssURLRe.ReplaceAllStringFunc(string(data), func(m string) string {
|
|
sub := cssURLRe.FindStringSubmatch(m)
|
|
ref := sub[2]
|
|
if strings.Contains(ref, ":") || strings.HasPrefix(ref, "/") || strings.HasPrefix(ref, "#") {
|
|
return m
|
|
}
|
|
clean := ref
|
|
suffix := ""
|
|
if i := strings.IndexAny(clean, "?#"); i >= 0 {
|
|
clean, suffix = clean[:i], clean[i:]
|
|
}
|
|
target := path.Clean(path.Join(path.Dir(rel), clean))
|
|
if u, ok := a.urls[target]; ok {
|
|
return "url(" + sub[1] + u + suffix + sub[3] + ")"
|
|
}
|
|
return m
|
|
}))
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
ext := path.Ext(rel)
|
|
hashed := strings.TrimSuffix(rel, ext) + "." + hex.EncodeToString(sum[:])[:10] + ext
|
|
if out != "" { // "" only computes the names, for rendering one page
|
|
dst := filepath.Join(out, filepath.FromSlash(hashed))
|
|
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := os.WriteFile(dst, data, 0o644); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
a.urls[rel] = "/" + hashed
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// copyTree copies a folder as it is, for static/ and the non-Markdown files
|
|
// that live beside pages in content/.
|
|
func copyTree(src, dst string, skip func(rel string, d fs.DirEntry) bool) error {
|
|
err := filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel(src, p)
|
|
if rel == "." {
|
|
return nil
|
|
}
|
|
if d.Name() == ".git" || d.Name() == ".DS_Store" {
|
|
if d.IsDir() {
|
|
return fs.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if skip != nil && skip(filepath.ToSlash(rel), d) {
|
|
return nil
|
|
}
|
|
target := filepath.Join(dst, rel)
|
|
if d.IsDir() {
|
|
return os.MkdirAll(target, 0o755)
|
|
}
|
|
if d.Type()&fs.ModeSymlink != 0 {
|
|
return fmt.Errorf("%s is a symlink; copy the file in instead", p)
|
|
}
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(target, data, 0o644)
|
|
})
|
|
if os.IsNotExist(err) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
// add publishes a file hotdog-cms supplies itself (such as the forms script) as
|
|
// an asset, fingerprinted like the site's own. A site file of the same name
|
|
// wins, so a site can replace it.
|
|
func (a *Assets) add(out, rel string, data []byte) error {
|
|
if _, ok := a.urls[rel]; ok {
|
|
return nil
|
|
}
|
|
sum := sha256.Sum256(data)
|
|
ext := path.Ext(rel)
|
|
hashed := strings.TrimSuffix(rel, ext) + "." + hex.EncodeToString(sum[:])[:10] + ext
|
|
if out != "" {
|
|
if err := os.WriteFile(filepath.Join(out, filepath.FromSlash(hashed)), data, 0o644); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
a.urls[rel] = "/" + hashed
|
|
return nil
|
|
}
|