From 05ece0bd8eb8753d3c78bef397203dd7645b4e05 Mon Sep 17 00:00:00 2001 From: Sora39831 <540587985@qq.com> Date: Tue, 7 Apr 2026 11:59:25 +0800 Subject: [PATCH] feat: add fingerprinted asset generator --- web/assetsgen/generator.go | 114 ++++++++++++++++++++++ web/assetsgen/generator_test.go | 165 ++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 web/assetsgen/generator.go create mode 100644 web/assetsgen/generator_test.go diff --git a/web/assetsgen/generator.go b/web/assetsgen/generator.go new file mode 100644 index 00000000..0630ecaf --- /dev/null +++ b/web/assetsgen/generator.go @@ -0,0 +1,114 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +type Manifest map[string]string + +type Options struct { + SourceDir string + OutputDir string + HashLen int +} + +func Generate(opts Options) (Manifest, error) { + if opts.HashLen <= 0 { + opts.HashLen = 8 + } + if opts.HashLen > sha256.Size*2 { + opts.HashLen = sha256.Size * 2 + } + + manifest := make(Manifest) + if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil { + return nil, err + } + + err := filepath.WalkDir(opts.SourceDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + + rel, err := filepath.Rel(opts.SourceDir, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + + raw, err := os.ReadFile(path) + if err != nil { + return err + } + + sum := sha256.Sum256(raw) + hash := hex.EncodeToString(sum[:])[:opts.HashLen] + target := fingerprint(rel, hash) + targetPath := filepath.Join(opts.OutputDir, filepath.FromSlash(target)) + + if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + if err := os.WriteFile(targetPath, raw, 0o644); err != nil { + return err + } + + manifest[rel] = target + return nil + }) + if err != nil { + return nil, err + } + + return manifest, nil +} + +func WriteManifest(path string, manifest Manifest) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + raw, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + raw = append(raw, '\n') + return os.WriteFile(path, raw, 0o644) +} + +func fingerprint(rel, hash string) string { + name := filepath.Base(rel) + if strings.HasPrefix(name, ".") && strings.Count(name, ".") == 1 { + return rel + "." + hash + } + + ext := filepath.Ext(rel) + base := strings.TrimSuffix(rel, ext) + if ext == "" { + return rel + "." + hash + } + return base + "." + hash + ext +} + +func CopyFile(dst string, src io.Reader) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + f, err := os.Create(dst) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, src) + return err +} diff --git a/web/assetsgen/generator_test.go b/web/assetsgen/generator_test.go new file mode 100644 index 00000000..4133cf13 --- /dev/null +++ b/web/assetsgen/generator_test.go @@ -0,0 +1,165 @@ +package assetsgen + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGenerateProducesFingerprintManifestAndFiles(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "js"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "js", "app.js"), []byte("console.log('v1')\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + got, ok := manifest["js/app.js"] + if !ok { + t.Fatalf("manifest missing logical path: %#v", manifest) + } + + sum := sha256.Sum256([]byte("console.log('v1')\n")) + wantHash := hex.EncodeToString(sum[:])[:8] + want := "js/app." + wantHash + ".js" + if got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", got)); err != nil { + t.Fatalf("hashed output missing: %v", err) + } + + defaultManifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "default-assets"), + }) + if err != nil { + t.Fatalf("Generate with default hash length returned error: %v", err) + } + + if gotDefault := defaultManifest["js/app.js"]; gotDefault != want { + t.Fatalf("default HashLen mismatch: got %q want %q", gotDefault, want) + } + + if _, err := os.Stat(filepath.Join(dst, "default-assets", want)); err != nil { + t.Fatalf("default hashed output missing: %v", err) + } +} + +func TestGenerateClampsHashLenToSha256HexLength(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.WriteFile(filepath.Join(src, "main.css"), []byte("body{}\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 65, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + sum := sha256.Sum256([]byte("body{}\n")) + wantHash := hex.EncodeToString(sum[:]) + want := "main." + wantHash + ".css" + if got := manifest["main.css"]; got != want { + t.Fatalf("unexpected hashed filename: got %q want %q", got, want) + } + + if _, err := os.Stat(filepath.Join(dst, "assets", want)); err != nil { + t.Fatalf("clamped hashed output missing: %v", err) + } +} + +func TestGenerateFingerprintsDotfilesWithoutLeadingExtension(t *testing.T) { + src := t.TempDir() + dst := t.TempDir() + + if err := os.MkdirAll(filepath.Join(src, "dir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, ".env"), []byte("ROOT=1\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dir", ".env"), []byte("NESTED=1\n"), 0o644); err != nil { + t.Fatal(err) + } + + manifest, err := Generate(Options{ + SourceDir: src, + OutputDir: filepath.Join(dst, "assets"), + HashLen: 8, + }) + if err != nil { + t.Fatalf("Generate returned error: %v", err) + } + + rootSum := sha256.Sum256([]byte("ROOT=1\n")) + rootWant := ".env." + hex.EncodeToString(rootSum[:])[:8] + if got := manifest[".env"]; got != rootWant { + t.Fatalf("unexpected root dotfile fingerprint: got %q want %q", got, rootWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", rootWant)); err != nil { + t.Fatalf("root dotfile output missing: %v", err) + } + + nestedSum := sha256.Sum256([]byte("NESTED=1\n")) + nestedWant := filepath.ToSlash(filepath.Join("dir", ".env.")) + hex.EncodeToString(nestedSum[:])[:8] + if got := manifest["dir/.env"]; got != nestedWant { + t.Fatalf("unexpected nested dotfile fingerprint: got %q want %q", got, nestedWant) + } + if _, err := os.Stat(filepath.Join(dst, "assets", filepath.FromSlash(nestedWant))); err != nil { + t.Fatalf("nested dotfile output missing: %v", err) + } +} + +func TestWriteManifestSerializesStableJson(t *testing.T) { + dst := t.TempDir() + path := filepath.Join(dst, "assets-manifest.json") + manifest := Manifest{ + "css/a.css": "css/a.11111111.css", + "js/b.js": "js/b.22222222.js", + } + + if err := WriteManifest(path, manifest); err != nil { + t.Fatalf("WriteManifest returned error: %v", err) + } + + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + want := "{\n \"css/a.css\": \"css/a.11111111.css\",\n \"js/b.js\": \"js/b.22222222.js\"\n}\n" + if string(raw) != want { + t.Fatalf("unexpected manifest json:\n got: %q\nwant: %q", string(raw), want) + } + + var decoded map[string]string + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatalf("manifest json invalid: %v", err) + } + if decoded["js/b.js"] != "js/b.22222222.js" { + t.Fatalf("unexpected manifest entry: %#v", decoded) + } +}