serve a bundle at its permalink
-site (or KHOSRA_SITE) opens the site root through content.OpenSite, so every
read keeps the os.Root guarantee. A path is a bundle key: /{section}/{slug}/
serves, the slashless form redirects permanently to it (ADR-0008), anything
unknown is 404. Render failure logs and returns a bare 500 rather than leaking a
template or filesystem detail.
internal/render holds goldmark plus the embedded reference theme (ADR-0026):
base.html with a redefinable "main" block, and one stylesheet inlined through
.Style. Serving it at an asset route would have been a second routing case for no
gain, and static serving belongs to a later entry.
Evidence beyond the tests: the binary against a real site root returns 200 with
<h1>About</h1> and the rendered body, 301 from /pages/about to /pages/about/, and
404 for /nope/. A Bengali variant is scanned but not yet reachable — that is the
next entry.
theme-contract.md gains a "Live today" section listing the six fields and two
named templates a theme may now rely on; the rest stays marked as shape.
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
// Command khosra serves a site root over HTTP.
|
||||
//
|
||||
// Everything is assembled here and nowhere else: no init(), no package-level state (conventions.md).
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
"khosra/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
site := flag.String("site", os.Getenv("KHOSRA_SITE"), "path to the site root (or KHOSRA_SITE)")
|
||||
addr := flag.String("addr", "localhost:8080", "address to listen on")
|
||||
flag.Parse()
|
||||
|
||||
if *site == "" {
|
||||
fatal("no site root: pass -site or set KHOSRA_SITE", nil)
|
||||
}
|
||||
fsys, err := content.OpenSite(*site)
|
||||
if err != nil {
|
||||
fatal("cannot open the site root", err)
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
fatal("cannot read content", err)
|
||||
}
|
||||
renderer, err := render.New()
|
||||
if err != nil {
|
||||
fatal("cannot prepare the reference theme", err)
|
||||
}
|
||||
|
||||
slog.Info("serving", "site", *site, "bundles", len(bundles), "addr", *addr)
|
||||
if err := http.ListenAndServe(*addr, web.Handler(content.NewSite(bundles), renderer)); err != nil {
|
||||
fatal("server stopped", err)
|
||||
}
|
||||
}
|
||||
|
||||
// fatal reports a startup failure and exits. Startup failure is fatal and loud; request-time failure
|
||||
// degrades instead (conventions.md).
|
||||
func fatal(msg string, err error) {
|
||||
if err != nil {
|
||||
slog.Error(msg, "err", err)
|
||||
} else {
|
||||
slog.Error(msg)
|
||||
}
|
||||
os.Exit(1)
|
||||
}
|
||||
+12
-8
@@ -1,6 +1,6 @@
|
||||
# State
|
||||
|
||||
**Verified against:** `d66c3cb` on 2026-07-30 — update this line every change.
|
||||
**Verified against:** `HEAD` on 2026-07-30 — update this line every change.
|
||||
If this file disagrees with the code, the code is right and this file is a bug.
|
||||
|
||||
## Inventory
|
||||
@@ -8,11 +8,15 @@ If this file disagrees with the code, the code is right and this file is a bug.
|
||||
| File | Purpose | LOC |
|
||||
|---|---|---|
|
||||
| `go.mod` | module `khosra`; `x/text`, `yaml.v3` direct | 8 |
|
||||
| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop | 217 |
|
||||
| `internal/content/content_test.go` | table-driven; symlink-escape evidence for the path guard | 155 |
|
||||
| `internal/content/content.go` | site root → bundles: `os.Root` open, walk, frontmatter split, key/lang derivation, NFC, collision drop, key index | 245 |
|
||||
| `internal/render/render.go` | goldmark + the embedded reference theme; `Page` is what templates receive | 92 |
|
||||
| `internal/render/templates/` | reference theme: `base.html`, `theme.css` (ADR-0026) | — |
|
||||
| `internal/web/web.go` | one routing case: path → bundle key, canonical trailing slash, 404, degrade on render failure | 62 |
|
||||
| `cmd/khosra/main.go` | flags, wiring, startup — the only place things are assembled | 50 |
|
||||
| `*_test.go` | table-driven; symlink escape, permalink, redirect, 404 | 245 |
|
||||
|
||||
No HTTP yet. This repo holds engine source only — the site root is external and passed with `-site`
|
||||
(ADR-0011).
|
||||
Serves a bundle at `/{section}/{slug}/`. This repo holds engine source only — the site root is external
|
||||
and passed with `-site` (ADR-0011).
|
||||
|
||||
Dependencies: none.
|
||||
|
||||
@@ -24,13 +28,13 @@ this change*.
|
||||
| Counter | Now | Extraction due at | What it buys |
|
||||
|---|---|---|---|
|
||||
| Render transforms | 0 | **3** | Stage pipeline (ordered `func(ctx,*Page) error`) |
|
||||
| Routing cases | 0 | **2** | Resolver extraction |
|
||||
| Routing cases | 1 | **2** | Resolver extraction |
|
||||
| Collection pages | 0 | **1** | Query primitive |
|
||||
| Views / output formats | 0 | **2** | View layer (contract per `theme-contract.md`) |
|
||||
| Views / output formats | 1 | **2** | View layer (contract per `theme-contract.md`) |
|
||||
| Effects | 0 | **2** | Effect runner + trigger wiring (change / schedule / demand) |
|
||||
| Extensions | 0 | **3** | Extension registry + wire file (`extensions.md`) |
|
||||
| Interface implementations | — | **2** | The interface itself |
|
||||
| Non-stdlib dependencies | 2 direct, 7 modules | budget in `scripts/budgets.env` | — |
|
||||
| Non-stdlib dependencies | 3 direct | budget in `scripts/budgets.env` | — |
|
||||
|
||||
Allowlisted, in use: `goldmark` is not yet imported. Allowlist: `goldmark` (markdown), `golang.org/x/text` (NFC, ADR-0015),
|
||||
`gopkg.in/yaml.v3` (frontmatter, ADR-0020).
|
||||
|
||||
+19
-2
@@ -3,8 +3,25 @@
|
||||
What the engine promises a theme, and the only thing this repository is bound to (ADR-0023). A theme's
|
||||
markup, layout and styling are not the engine's business; a theme's *inputs* are.
|
||||
|
||||
**STATUS: not built.** This is the shape the contract takes when the first template renders. Everything
|
||||
here is engine obligation, not theme instruction — a theme may ignore any of it.
|
||||
**STATUS: partly live.** The fields under *Live today* exist and are gated; everything else is the shape
|
||||
the contract takes when the feature arrives. All of it is engine obligation, not theme instruction — a
|
||||
theme may ignore any of it.
|
||||
|
||||
## Live today
|
||||
|
||||
A bundle page receives:
|
||||
|
||||
| Field | Contents |
|
||||
|---|---|
|
||||
| `.Title` | may be empty; a template falls back to `.Key` rather than failing |
|
||||
| `.Lang` | the locale of this variant, always set |
|
||||
| `.Key` | the bundle's identity, without language or extension |
|
||||
| `.HTML` | the rendered body, already escaped |
|
||||
| `.Extra` | every frontmatter key the parser does not name (ADR-0002) |
|
||||
| `.Style` | the reference theme's stylesheet, inlined so a bare site root needs no asset route |
|
||||
|
||||
Two named templates: `base` is executed for every page; `main` is the block a theme redefines to change
|
||||
the body while inheriting the document. Nothing else is promised yet.
|
||||
|
||||
## The stability rule
|
||||
|
||||
|
||||
@@ -6,3 +6,5 @@ require (
|
||||
golang.org/x/text v0.40.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/yuin/goldmark v1.8.5
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
|
||||
@@ -215,3 +215,30 @@ func dropCollisions(all []Bundle) []Bundle {
|
||||
}
|
||||
return kept
|
||||
}
|
||||
|
||||
// Site is a set of bundles indexed for lookup by permalink key.
|
||||
type Site struct {
|
||||
byKeyLang map[string]Bundle
|
||||
}
|
||||
|
||||
// NewSite indexes bundles for lookup. Later variants of a key and language cannot occur, because Scan
|
||||
// drops ambiguity before this sees it.
|
||||
func NewSite(bundles []Bundle) *Site {
|
||||
s := &Site{byKeyLang: make(map[string]Bundle, len(bundles))}
|
||||
for _, b := range bundles {
|
||||
s.byKeyLang[b.Key+"\x00"+b.Lang] = b
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Lookup returns the default-locale variant of a key.
|
||||
//
|
||||
// Other languages are reachable only once language routing exists; until then a key means its default
|
||||
// variant (ADR-0021).
|
||||
func (s *Site) Lookup(key string) (Bundle, bool) {
|
||||
b, ok := s.byKeyLang[key+"\x00"+DefaultLang]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// Len reports how many bundles the site holds.
|
||||
func (s *Site) Len() int { return len(s.byKeyLang) }
|
||||
|
||||
@@ -153,3 +153,25 @@ func TestOpenSiteRefusesSymlinkEscape(t *testing.T) {
|
||||
t.Fatal("traversal with .. succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSiteLookupFindsDefaultVariant(t *testing.T) {
|
||||
fsys := fstest.MapFS{
|
||||
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nhi\n")},
|
||||
"content/pages/about.bn.md": {Data: []byte("---\ntitle: পরিচিতি\n---\nহাই\n")},
|
||||
}
|
||||
bundles, err := Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
site := NewSite(bundles)
|
||||
if site.Len() != 2 {
|
||||
t.Fatalf("indexed %d bundles, want 2", site.Len())
|
||||
}
|
||||
b, ok := site.Lookup("pages/about")
|
||||
if !ok || b.Title != "About" {
|
||||
t.Fatalf("Lookup gave %+v %v, want the English variant", b, ok)
|
||||
}
|
||||
if _, ok := site.Lookup("pages/missing"); ok {
|
||||
t.Error("Lookup invented a bundle")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package render turns a bundle into bytes: Markdown to HTML, then a template set. It knows content and
|
||||
// nothing about HTTP.
|
||||
//
|
||||
// The embedded templates and stylesheet are the reference theme (ADR-0026) — a demonstration of
|
||||
// docs/theme-contract.md, not a design. Fields a template may rely on are listed there.
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"html/template"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
//go:embed templates
|
||||
var themeFS embed.FS
|
||||
|
||||
// Page is what a template receives. Absence is the zero value: a template reads what exists and never
|
||||
// fails on a missing field (invariant 1).
|
||||
type Page struct {
|
||||
// Title may be empty; whether that is legal depends on a type, which nothing decides yet.
|
||||
Title string
|
||||
// Lang is the locale this variant is written in.
|
||||
Lang string
|
||||
// Key is the bundle's identity, useful for building links.
|
||||
Key string
|
||||
// HTML is the rendered body, already escaped by the Markdown renderer.
|
||||
HTML template.HTML
|
||||
// Extra carries every frontmatter key the parser does not name (ADR-0002).
|
||||
Extra map[string]any
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
}
|
||||
|
||||
// Renderer holds the parsed template set and the Markdown converter. Templates are parsed once, never
|
||||
// per request (conventions.md).
|
||||
type Renderer struct {
|
||||
tmpl *template.Template
|
||||
md goldmark.Markdown
|
||||
style template.CSS
|
||||
}
|
||||
|
||||
// New parses the reference theme and prepares the Markdown converter.
|
||||
//
|
||||
// A malformed embedded template is a programming error caught at startup, not at request time, so this
|
||||
// returns an error and the caller is expected to treat it as fatal.
|
||||
func New() (*Renderer, error) {
|
||||
tmpl, err := template.ParseFS(themeFS, "templates/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse reference theme: %w", err)
|
||||
}
|
||||
css, err := themeFS.ReadFile("templates/theme.css")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read reference stylesheet: %w", err)
|
||||
}
|
||||
return &Renderer{tmpl: tmpl, md: goldmark.New(), style: template.CSS(css)}, nil
|
||||
}
|
||||
|
||||
// Bundle renders one bundle into a complete page.
|
||||
func (r *Renderer) Bundle(b content.Bundle) ([]byte, error) {
|
||||
var body bytes.Buffer
|
||||
if err := r.md.Convert(b.Body, &body); err != nil {
|
||||
return nil, fmt.Errorf("markdown %s: %w", b.Path, err)
|
||||
}
|
||||
p := Page{
|
||||
Title: b.Title,
|
||||
Lang: b.Lang,
|
||||
Key: b.Key,
|
||||
HTML: template.HTML(body.String()),
|
||||
Extra: b.Extra,
|
||||
Style: r.style,
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := r.tmpl.ExecuteTemplate(&out, "base", p); err != nil {
|
||||
return nil, fmt.Errorf("template %s: %w", b.Key, err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"khosra/internal/content"
|
||||
)
|
||||
|
||||
func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
|
||||
r, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\n\n# Heading\n\nSome *prose*.\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{
|
||||
"<!doctype html>", `<html lang="en">`, "<title>Hello</title>",
|
||||
"<h1>Hello</h1>", "<em>prose</em>", "<style>",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
|
||||
r, err := New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := content.Parse("status/note.md", []byte("just a note\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(out), "<title>status/note</title>") {
|
||||
t.Errorf("a titleless bundle must still produce a title element:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "base" -}}
|
||||
<!doctype html>
|
||||
<html lang="{{.Lang}}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</title>
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
{{block "main" . -}}
|
||||
<article>
|
||||
{{if .Title}}<h1>{{.Title}}</h1>{{end}}
|
||||
{{.HTML}}
|
||||
</article>
|
||||
{{- end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
@@ -0,0 +1,13 @@
|
||||
/* Reference theme: legibility only, no design opinions (ADR-0026). */
|
||||
html { font-family: Georgia, serif; line-height: 1.6; color: #1a1a1a; background: #fdfdfb; }
|
||||
main { max-width: 34rem; margin: 3rem auto; padding: 0 1rem; }
|
||||
h1, h2, h3 { line-height: 1.25; font-weight: 600; }
|
||||
a { color: #1a4d7a; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
pre, code { font-family: ui-monospace, monospace; font-size: 0.9em; }
|
||||
pre { overflow-x: auto; padding: 0.75rem; background: #f3f2ee; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html { color: #e8e6e1; background: #16161a; }
|
||||
a { color: #8ab4dd; }
|
||||
pre { background: #22222a; }
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package web maps requests to bundles and writes bytes. It knows content and render, and exposes
|
||||
// neither to them.
|
||||
package web
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
// Handler serves a site.
|
||||
//
|
||||
// One routing case for now: a path is a bundle key. Extracting a resolver waits for the second case,
|
||||
// which language routing brings — check the counter in docs/state.md rather than anticipating it.
|
||||
func Handler(site *content.Site, r *render.Renderer) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, req *http.Request) {
|
||||
serve(w, req, site, r)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// serve resolves one request.
|
||||
//
|
||||
// The canonical form of every bundle URL ends in a slash (ADR-0008), so a slashless path that names a
|
||||
// bundle redirects permanently rather than serving a second URL for the same content.
|
||||
func serve(w http.ResponseWriter, req *http.Request, site *content.Site, r *render.Renderer) {
|
||||
p := req.URL.Path
|
||||
if p == "/" {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
key := content.Normalise(strings.Trim(p, "/"))
|
||||
if !strings.HasSuffix(p, "/") {
|
||||
if _, ok := site.Lookup(key); ok {
|
||||
http.Redirect(w, req, p+"/", http.StatusMovedPermanently)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
b, ok := site.Lookup(key)
|
||||
if !ok {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
out, err := r.Bundle(b)
|
||||
if err != nil {
|
||||
// A render failure degrades: log it and say nothing more to the client than that it failed
|
||||
// (conventions.md). It must never leak a template or filesystem detail.
|
||||
slog.Error("render failed", "key", b.Key, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if _, err := w.Write(out); err != nil {
|
||||
slog.Warn("write failed", "key", b.Key, "err", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"khosra/internal/content"
|
||||
"khosra/internal/render"
|
||||
)
|
||||
|
||||
func testHandler(t *testing.T) http.Handler {
|
||||
t.Helper()
|
||||
fsys := fstest.MapFS{
|
||||
"content/pages/about.md": {Data: []byte("---\ntitle: About\n---\nAbout me.\n")},
|
||||
"content/posts/hello/index.md": {Data: []byte("---\ntitle: Hello\n---\nFirst post.\n")},
|
||||
}
|
||||
bundles, err := content.Scan(fsys)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := render.New()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Handler(content.NewSite(bundles), r)
|
||||
}
|
||||
|
||||
func TestServeBundleAtItsPermalink(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
for _, path := range []string{"/pages/about/", "/posts/hello/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("GET %s = %d, want 200", path, rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("GET %s content-type = %q", path, ct)
|
||||
}
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about/", nil))
|
||||
if body := rec.Body.String(); !strings.Contains(body, "<h1>About</h1>") || !strings.Contains(body, "About me.") {
|
||||
t.Errorf("body did not render the bundle:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlashlessPathRedirectsPermanently(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/pages/about", nil))
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("got %d, want 301", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != "/pages/about/" {
|
||||
t.Errorf("Location = %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownPathsAre404(t *testing.T) {
|
||||
h := testHandler(t)
|
||||
for _, path := range []string{"/", "/nope/", "/pages/nope", "/pages/about/deeper/"} {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404", path, rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user