Files

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
}