diff --git a/deploy/deploy.go b/deploy/deploy.go
index b2a8a8813..a69e974b7 100644
--- a/deploy/deploy.go
+++ b/deploy/deploy.go
@@ -133,10 +133,14 @@ func (d *Deployer) Deploy(ctx context.Context) error {
// Load local files from the source directory.
var include, exclude glob.Glob
+ var mappath func(string) string
if d.target != nil {
include, exclude = d.target.IncludeGlob, d.target.ExcludeGlob
+ if d.target.StripIndexHTML {
+ mappath = stripIndexHTML
+ }
}
- local, err := d.walkLocal(d.localFs, d.cfg.Matchers, include, exclude, d.mediaTypes)
+ local, err := d.walkLocal(d.localFs, d.cfg.Matchers, include, exclude, d.mediaTypes, mappath)
if err != nil {
return err
}
@@ -483,7 +487,7 @@ func knownHiddenDirectory(name string) bool {
// walkLocal walks the source directory and returns a flat list of files,
// using localFile.SlashPath as the map keys.
-func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, include, exclude glob.Glob, mediaTypes media.Types) (map[string]*localFile, error) {
+func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, include, exclude glob.Glob, mediaTypes media.Types, mappath func(string) string) (map[string]*localFile, error) {
retval := map[string]*localFile{}
err := afero.Walk(fs, "", func(path string, info os.FileInfo, err error) error {
if err != nil {
@@ -529,6 +533,11 @@ func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, incl
break
}
}
+ // Apply any additional modifications to the local path, to map it to
+ // the remote path.
+ if mappath != nil {
+ slashpath = mappath(slashpath)
+ }
lf, err := newLocalFile(fs, path, slashpath, m, mediaTypes)
if err != nil {
return err
@@ -542,6 +551,15 @@ func (d *Deployer) walkLocal(fs afero.Fs, matchers []*deployconfig.Matcher, incl
return retval, nil
}
+// stripIndexHTML remaps keys matching "
/index.html" to "/".
+func stripIndexHTML(slashpath string) string {
+ const suffix = "/index.html"
+ if strings.HasSuffix(slashpath, suffix) {
+ return slashpath[:len(slashpath)-len(suffix)+1]
+ }
+ return slashpath
+}
+
// walkRemote walks the target bucket and returns a flat list.
func (d *Deployer) walkRemote(ctx context.Context, bucket *blob.Bucket, include, exclude glob.Glob) (map[string]*blob.ListObject, error) {
retval := map[string]*blob.ListObject{}
diff --git a/deploy/deploy_test.go b/deploy/deploy_test.go
index 12967fba0..17dffc25a 100644
--- a/deploy/deploy_test.go
+++ b/deploy/deploy_test.go
@@ -216,8 +216,9 @@ func TestFindDiffs(t *testing.T) {
func TestWalkLocal(t *testing.T) {
tests := map[string]struct {
- Given []string
- Expect []string
+ Given []string
+ Expect []string
+ MapPath func(string) string
}{
"Empty": {
Given: []string{},
@@ -235,6 +236,11 @@ func TestWalkLocal(t *testing.T) {
Given: []string{"file.txt", ".hidden_dir/file.txt", ".well-known/file.txt"},
Expect: []string{"file.txt", ".well-known/file.txt"},
},
+ "StripIndexHTML": {
+ Given: []string{"index.html", "file.txt", "dir/index.html", "dir/file.txt"},
+ Expect: []string{"index.html", "file.txt", "dir/", "dir/file.txt"},
+ MapPath: stripIndexHTML,
+ },
}
for desc, tc := range tests {
@@ -254,7 +260,7 @@ func TestWalkLocal(t *testing.T) {
}
}
d := newDeployer()
- if got, err := d.walkLocal(fs, nil, nil, nil, media.DefaultTypes); err != nil {
+ if got, err := d.walkLocal(fs, nil, nil, nil, media.DefaultTypes, tc.MapPath); err != nil {
t.Fatal(err)
} else {
expect := map[string]any{}
@@ -274,6 +280,63 @@ func TestWalkLocal(t *testing.T) {
}
}
+func TestStripIndexHTML(t *testing.T) {
+ tests := map[string]struct {
+ Input string
+ Output string
+ }{
+ "Unmapped": {Input: "normal_file.txt", Output: "normal_file.txt"},
+ "Stripped": {Input: "directory/index.html", Output: "directory/"},
+ "NoSlash": {Input: "prefix_index.html", Output: "prefix_index.html"},
+ "Root": {Input: "index.html", Output: "index.html"},
+ }
+ for desc, tc := range tests {
+ t.Run(desc, func(t *testing.T) {
+ got := stripIndexHTML(tc.Input)
+ if got != tc.Output {
+ t.Errorf("got %q, expect %q", got, tc.Output)
+ }
+ })
+ }
+}
+
+func TestStripIndexHTMLMatcher(t *testing.T) {
+ // StripIndexHTML should not affect matchers.
+ fs := afero.NewMemMapFs()
+ if err := fs.Mkdir("dir", 0o755); err != nil {
+ t.Fatal(err)
+ }
+ for _, name := range []string{"index.html", "dir/index.html", "file.txt"} {
+ if fd, err := fs.Create(name); err != nil {
+ t.Fatal(err)
+ } else {
+ fd.Close()
+ }
+ }
+ d := newDeployer()
+ const pattern = `\.html$`
+ matcher := &deployconfig.Matcher{Pattern: pattern, Gzip: true, Re: regexp.MustCompile(pattern)}
+ if got, err := d.walkLocal(fs, []*deployconfig.Matcher{matcher}, nil, nil, media.DefaultTypes, stripIndexHTML); err != nil {
+ t.Fatal(err)
+ } else {
+ for _, name := range []string{"index.html", "dir/"} {
+ lf := got[name]
+ if lf == nil {
+ t.Errorf("missing file %q", name)
+ } else if lf.matcher == nil {
+ t.Errorf("file %q has nil matcher, expect %q", name, pattern)
+ }
+ }
+ const name = "file.txt"
+ lf := got[name]
+ if lf == nil {
+ t.Errorf("missing file %q", name)
+ } else if lf.matcher != nil {
+ t.Errorf("file %q has matcher %q, expect nil", name, lf.matcher.Pattern)
+ }
+ }
+}
+
func TestLocalFile(t *testing.T) {
const (
content = "hello world!"
diff --git a/deploy/deployconfig/deployConfig.go b/deploy/deployconfig/deployConfig.go
index b5b8973b2..b16b7c627 100644
--- a/deploy/deployconfig/deployConfig.go
+++ b/deploy/deployconfig/deployConfig.go
@@ -69,6 +69,11 @@ type Target struct {
// Parsed versions of Include/Exclude.
IncludeGlob glob.Glob `json:"-"`
ExcludeGlob glob.Glob `json:"-"`
+
+ // If true, any local path matching /index.html will be mapped to the
+ // remote path /. This does not affect the top-level index.html file,
+ // since that would result in an empty path.
+ StripIndexHTML bool
}
func (tgt *Target) ParseIncludeExclude() error {
diff --git a/docs/content/en/hosting-and-deployment/hugo-deploy.md b/docs/content/en/hosting-and-deployment/hugo-deploy.md
index 45b917f1e..db2448ee7 100644
--- a/docs/content/en/hosting-and-deployment/hugo-deploy.md
+++ b/docs/content/en/hosting-and-deployment/hugo-deploy.md
@@ -186,6 +186,15 @@ URL = ""
#include = "**.html" # would only include files with ".html" suffix
#exclude = "**.{jpg, png}" # would exclude files with ".jpg" or ".png" suffix
+# Map any file named "/index.html" to the remote file "/". This does
+# not affect the root "index.html" file, and it does not affect matchers below.
+# This works when deploying to key-value cloud storage systems, such as Amazon
+# S3 (general purpose buckets, not directory buckets), Google Cloud Storage, and
+# Azure Blob Storage. This makes it so the canonical URL will match the object
+# key in cloud storage, except for the root index.html file.
+#
+#stripIndexHTML = true
+
#######################
[[deployment.matchers]]
@@ -195,6 +204,7 @@ URL = ""
# See https://golang.org/pkg/regexp/syntax/ for pattern syntax.
# Pattern searching is stopped on first match.
+# This is not affected by stripIndexHTML, above.
pattern = ""
# If true, Hugo will gzip the file before uploading it to the bucket.