diff --git a/commands/commands_test.go b/commands/commands_test.go index 97d81ec6ee9..d37ec1f0d6c 100644 --- a/commands/commands_test.go +++ b/commands/commands_test.go @@ -368,6 +368,11 @@ Content Single: {{ .Title }} +`) + + writeFile(t, filepath.Join(dir, "layouts", "404.html"), ` +404: {{ .Title }}|Not Found. + `) writeFile(t, filepath.Join(dir, "layouts", "_default", "list.html"), ` diff --git a/commands/server.go b/commands/server.go index f082164cee9..7689f03dbc4 100644 --- a/commands/server.go +++ b/commands/server.go @@ -412,12 +412,18 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string // See https://docs.netlify.com/routing/redirects/rewrites-proxies/ if !redirect.Force { path := filepath.Clean(strings.TrimPrefix(requestURI, u.Path)) - fi, err := f.c.hugo().BaseFs.PublishFs.Stat(path) + if root != "" { + path = filepath.Join(root, path) + } + fs := f.c.publishDirServerFs + + fi, err := fs.Stat(path) + if err == nil { if fi.IsDir() { // There will be overlapping directories, so we // need to check for a file. - _, err = f.c.hugo().BaseFs.PublishFs.Stat(filepath.Join(path, "index.html")) + _, err = fs.Stat(filepath.Join(path, "index.html")) doRedirect = err != nil } else { doRedirect = false @@ -426,15 +432,28 @@ func (f *fileServer) createEndpoint(i int) (*http.ServeMux, net.Listener, string } if doRedirect { - if redirect.Status == 200 { + switch redirect.Status { + case 404: + w.WriteHeader(404) + file, err := fs.Open(filepath.FromSlash(strings.TrimPrefix(redirect.To, u.Path))) + if err == nil { + defer file.Close() + io.Copy(w, file) + } else { + fmt.Fprintln(w, "

Page Not Found

") + } + return + case 200: if r2 := f.rewriteRequest(r, strings.TrimPrefix(redirect.To, u.Path)); r2 != nil { requestURI = redirect.To r = r2 } - } else { + fallthrough + default: w.Header().Set("Content-Type", "") http.Redirect(w, r, redirect.To, redirect.Status) return + } } diff --git a/commands/server_test.go b/commands/server_test.go index 56d3949ee83..47cc8b9e9bf 100644 --- a/commands/server_test.go +++ b/commands/server_test.go @@ -41,12 +41,30 @@ func TestServerPanicOnConfigError(t *testing.T) { linenos='table' ` - r := runServerTest(c, 0, config) + r := runServerTest(c, + serverTestOptions{ + config: config, + }, + ) c.Assert(r.err, qt.IsNotNil) c.Assert(r.err.Error(), qt.Contains, "cannot parse 'Highlight.LineNos' as bool:") } +func TestServer404(t *testing.T) { + c := qt.New(t) + + r := runServerTest(c, + serverTestOptions{ + test404: true, + getNumHomes: 1, + }, + ) + + c.Assert(r.err, qt.IsNil) + c.Assert(r.content404, qt.Contains, "404: 404 Page not found|Not Found.") +} + func TestServerFlags(t *testing.T) { c := qt.New(t) @@ -81,7 +99,13 @@ baseURL="https://example.org" args = strings.Split(test.flag, "=") } - r := runServerTest(c, 1, config, args...) + opts := serverTestOptions{ + config: config, + args: args, + getNumHomes: 1, + } + + r := runServerTest(c, opts) test.assert(c, r) @@ -140,7 +164,16 @@ baseURL="https://example.org" if test.flag != "" { args = strings.Split(test.flag, "=") } - r := runServerTest(c, test.numservers, test.config, args...) + + opts := serverTestOptions{ + config: test.config, + getNumHomes: test.numservers, + test404: true, + args: args, + } + + r := runServerTest(c, opts) + c.Assert(r.content404, qt.Contains, "404: 404 Page not found|Not Found.") test.assert(c, r) }) @@ -152,11 +185,19 @@ baseURL="https://example.org" type serverTestResult struct { err error homesContent []string + content404 string publicDirnames map[string]bool } -func runServerTest(c *qt.C, getNumHomes int, config string, args ...string) (result serverTestResult) { - dir := createSimpleTestSite(c, testSiteConfig{configTOML: config}) +type serverTestOptions struct { + getNumHomes int + test404 bool + config string + args []string +} + +func runServerTest(c *qt.C, opts serverTestOptions) (result serverTestResult) { + dir := createSimpleTestSite(c, testSiteConfig{configTOML: opts.config}) sp, err := helpers.FindAvailablePort() c.Assert(err, qt.IsNil) @@ -172,7 +213,7 @@ func runServerTest(c *qt.C, getNumHomes int, config string, args ...string) (res scmd := b.newServerCmdSignaled(stop) cmd := scmd.getCommand() - args = append([]string{"-s=" + dir, fmt.Sprintf("-p=%d", port)}, args...) + args := append([]string{"-s=" + dir, fmt.Sprintf("-p=%d", port)}, opts.args...) cmd.SetArgs(args) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) @@ -184,12 +225,12 @@ func runServerTest(c *qt.C, getNumHomes int, config string, args ...string) (res return err }) - if getNumHomes > 0 { + if opts.getNumHomes > 0 { // Esp. on slow CI machines, we need to wait a little before the web // server is ready. time.Sleep(567 * time.Millisecond) - result.homesContent = make([]string, getNumHomes) - for i := 0; i < getNumHomes; i++ { + result.homesContent = make([]string, opts.getNumHomes) + for i := 0; i < opts.getNumHomes; i++ { func() { resp, err := http.Get(fmt.Sprintf("http://localhost:%d/", port+i)) c.Check(err, qt.IsNil) @@ -202,6 +243,16 @@ func runServerTest(c *qt.C, getNumHomes int, config string, args ...string) (res } } + if opts.test404 { + resp, err := http.Get(fmt.Sprintf("http://localhost:%d/this-page-does-not-exist", port)) + c.Check(err, qt.IsNil) + c.Check(resp.StatusCode, qt.Equals, http.StatusNotFound) + if err == nil { + defer resp.Body.Close() + result.content404 = helpers.ReaderToString(resp.Body) + } + } + time.Sleep(1 * time.Second) select { diff --git a/config/commonConfig.go b/config/commonConfig.go index efaa589d18e..31705841ef2 100644 --- a/config/commonConfig.go +++ b/config/commonConfig.go @@ -180,10 +180,15 @@ type Headers struct { } type Redirect struct { - From string - To string + From string + To string + + // HTTP status code to use for the redirect. + // A status code of 200 will trigger a URL rewrite. Status int - Force bool + + // Forcode redirect, even if original request path exists. + Force bool } func (r Redirect) IsZero() bool { @@ -200,16 +205,31 @@ func DecodeServer(cfg Provider) (*Server, error) { _ = mapstructure.WeakDecode(m, s) for i, redir := range s.Redirects { - // Get it in line with the Hugo server. - redir.To = strings.TrimSuffix(redir.To, "index.html") - if !strings.HasPrefix(redir.To, "https") && !strings.HasSuffix(redir.To, "/") { - // There are some tricky infinite loop situations when dealing - // when the target does not have a trailing slash. - // This can certainly be handled better, but not time for that now. - return nil, fmt.Errorf("unsupported redirect to value %q in server config; currently this must be either a remote destination or a local folder, e.g. \"/blog/\" or \"/blog/index.html\"", redir.To) + // Get it in line with the Hugo server for OK responses. + // We currently treat the 404 as a special case, they are always "ugly", so keep them as is. + if redir.Status != 404 { + redir.To = strings.TrimSuffix(redir.To, "index.html") + if !strings.HasPrefix(redir.To, "https") && !strings.HasSuffix(redir.To, "/") { + // There are some tricky infinite loop situations when dealing + // when the target does not have a trailing slash. + // This can certainly be handled better, but not time for that now. + return nil, fmt.Errorf("unsupported redirect to value %q in server config; currently this must be either a remote destination or a local folder, e.g. \"/blog/\" or \"/blog/index.html\"", redir.To) + } } s.Redirects[i] = redir } + if len(s.Redirects) == 0 { + // Set up a default redirect for 404s. + s.Redirects = []Redirect{ + { + From: "**", + To: "/404.html", + Status: 404, + }, + } + + } + return s, nil } diff --git a/docs/content/en/getting-started/configuration.md b/docs/content/en/getting-started/configuration.md index 91279712a5d..4f1efefb3d3 100644 --- a/docs/content/en/getting-started/configuration.md +++ b/docs/content/en/getting-started/configuration.md @@ -550,6 +550,19 @@ force = false {{< new-in "0.76.0" >}} Setting `force=true` will make a redirect even if there is existing content in the path. Note that before Hugo 0.76 `force` was the default behaviour, but this is inline with how Netlify does it. +## 404 Server Error Page + +{{< new-in "0.103.0" >}} + +Hugo will, by default, render all 404 errors when running `hugo server` with the `404.html` template. Note that if you have already added one or more redirects to your [Server Config](#server-config), you need to add the 404 redirect explicitly, e.g: + +```toml +[[redirects]] + from = "/**" + to = "/404.html" + status = 404 +``` + ## Configure Title Case Set `titleCaseStyle` to specify the title style used by the [title](/functions/title/) template function and the automatic section titles in Hugo. It defaults to [AP Stylebook](https://www.apstylebook.com/) for title casing, but you can also set it to `Chicago` or `Go` (every word starts with a capital letter).