serve robots.txt and sitemap.xml
Two exact paths a crawler asks for by name, so they are mux entries rather than resolver cases — no bundle can collide, since a key always sits under a section. robots.txt at the site root is served verbatim, because a site that ships one has said something deliberate; otherwise the engine emits the minimum that is true and points at the sitemap. The sitemap lists every bundle in every language it exists in, since each variant is separately reachable, with lastmod only where a bundle has a date. Every URL comes from content.URL like every other path the engine emits, so a sitemap cannot disagree with what is actually served. Both need a declared base. Without one the sitemap answers 404 rather than listing paths no crawler can resolve, and robots omits the Sitemap line rather than writing a relative one. write() was setting text/html for every caller, and headers only go out with the first byte — so a handler setting its own type would have had it silently replaced, which is how a sitemap gets served as a web page. It now splits into write and writeAs, and the tests assert the content types rather than only the bodies.
This commit is contained in:
@@ -61,7 +61,7 @@ func TestDatesReadInTheirOwnScript(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTypographerSmoothsProseAndLeavesCodeAlone(t *testing.T) {
|
||||
r, err := New(nil, nil)
|
||||
r, err := New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ func TestTypographerSmoothsProseAndLeavesCodeAlone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestMachineReadableOutputStaysASCII(t *testing.T) {
|
||||
r, err := New(nil, nil)
|
||||
r, err := New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ type head struct {
|
||||
Alternates []Alternate
|
||||
// Style is the reference theme's stylesheet, inlined so a bare site root needs no asset route.
|
||||
Style template.CSS
|
||||
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
|
||||
Site content.Settings
|
||||
}
|
||||
|
||||
// Page is one bundle rendered.
|
||||
@@ -117,6 +119,8 @@ type Renderer struct {
|
||||
style template.CSS
|
||||
// files is the site root, handed to features through Origin. Nil when there is none.
|
||||
files fs.FS
|
||||
// settings are the site's declarations, constant for the life of the process.
|
||||
settings content.Settings
|
||||
}
|
||||
|
||||
// Partial renders a named fragment. A feature under internal/ext is handed one of these at wiring time,
|
||||
@@ -169,7 +173,7 @@ func WithOrigin(pc parser.Context, origin Origin) {
|
||||
// extensions to enable. A callback rather than a parameter of feature types, because internal/render must
|
||||
// not import internal/ext — only cmd knows which features a build includes (conventions.md, ADR-0036). It
|
||||
// may be nil.
|
||||
func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
|
||||
func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmark.Extender) (*Renderer, error) {
|
||||
page, err := parseSet(siteFS, "templates/base.html", "templates/page.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("bundle templates: %w", err)
|
||||
@@ -186,7 +190,7 @@ func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, err
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS}
|
||||
r := &Renderer{page: page, list: list, partials: partials, style: css, files: siteFS, settings: settings}
|
||||
// The typographer smooths quotes, dashes and ellipses in authored prose and leaves code spans alone,
|
||||
// because it works on the parsed tree rather than the text. That is the only change the engine makes to
|
||||
// an author's words (ADR-0034), and it is a parser option rather than a render transform, so it does
|
||||
@@ -202,6 +206,25 @@ func New(siteFS fs.FS, extend func(Partial) []goldmark.Extender) (*Renderer, err
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// head builds the document shell every kind of page shares.
|
||||
//
|
||||
// canonical arrives as a path and leaves absolute when the site declared a base: a canonical link and an
|
||||
// hreflang are read by machines that resolve neither against the page (ADR-0039).
|
||||
func (r *Renderer) head(title, lang, canonical string) head {
|
||||
return head{
|
||||
Title: title,
|
||||
Lang: lang,
|
||||
Canonical: r.absolute(canonical),
|
||||
Style: r.style,
|
||||
Site: r.settings,
|
||||
}
|
||||
}
|
||||
|
||||
// absolute is the site's own URL for a path the engine emitted, or the path itself when no base is declared.
|
||||
func (r *Renderer) absolute(path string) string {
|
||||
return content.Absolute(r.settings.Base, path)
|
||||
}
|
||||
|
||||
// Partial renders one named fragment. A missing template is an error the caller degrades on, never a
|
||||
// failed request (extensions.md rule 5).
|
||||
func (r *Renderer) Partial(name string, data Fragment) ([]byte, error) {
|
||||
@@ -276,14 +299,14 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
|
||||
title = b.Key
|
||||
}
|
||||
p := Page{
|
||||
head: head{Title: title, Lang: served, Canonical: content.URL(b.Key, served), Style: r.style},
|
||||
head: r.head(title, served, content.URL(b.Key, served)),
|
||||
Key: b.Key,
|
||||
HTML: template.HTML(body.String()),
|
||||
Extra: b.Extra,
|
||||
Sequence: r.sequence(seq, served),
|
||||
}
|
||||
for _, l := range variants {
|
||||
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: content.URL(b.Key, l)})
|
||||
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Key, l))})
|
||||
}
|
||||
return r.execute(r.page, p, b.Key)
|
||||
}
|
||||
@@ -363,7 +386,7 @@ func (r *Renderer) paginate(title, lang, canonical string, all []content.Bundle,
|
||||
start := (page - 1) * content.PerPage
|
||||
end := min(start+content.PerPage, len(all))
|
||||
l := List{
|
||||
head: head{Title: title, Lang: lang, Canonical: canonical, Style: r.style},
|
||||
head: r.head(title, lang, canonical),
|
||||
Page: page,
|
||||
Pages: pages,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
|
||||
r, err := New(nil, nil)
|
||||
r, err := New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestBundleRendersMarkdownIntoTheTheme(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestBundleWithoutTitleFallsBackToKey(t *testing.T) {
|
||||
r, err := New(nil, nil)
|
||||
r, err := New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestSiteOverridesOneBlockAndInheritsTheRest(t *testing.T) {
|
||||
siteFS := fstest.MapFS{
|
||||
"templates/page.html": {Data: []byte(`{{define "main"}}<section class="mine">{{.Title}}</section>{{end}}`)},
|
||||
}
|
||||
r, err := New(siteFS, nil)
|
||||
r, err := New(siteFS, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
|
||||
siteFS := fstest.MapFS{
|
||||
"templates/list.html": {Data: []byte(`{{define "main"}}LISTING ONLY{{end}}`)},
|
||||
}
|
||||
r, err := New(siteFS, nil)
|
||||
r, err := New(siteFS, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func TestAListingOverrideDoesNotLeakIntoBundlePages(t *testing.T) {
|
||||
|
||||
func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) {
|
||||
siteFS := fstest.MapFS{"templates/theme.css": {Data: []byte("body{color:rebeccapurple}")}}
|
||||
r, err := New(siteFS, nil)
|
||||
r, err := New(siteFS, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -114,3 +114,50 @@ func TestSiteStylesheetReplacesTheReferenceOne(t *testing.T) {
|
||||
t.Error("the site stylesheet should replace the reference one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeclaredBaseMakesMachineReadableURLsAbsolute(t *testing.T) {
|
||||
// A canonical link and an hreflang are read by machines that resolve neither against the page, so both
|
||||
// go absolute as soon as the site says where it lives (ADR-0039).
|
||||
r, err := New(nil, content.Settings{Base: "https://khosra.example", Title: "Khosra"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{
|
||||
`rel="canonical" href="https://khosra.example/posts/hello/"`,
|
||||
`hreflang="bn" href="https://khosra.example/bn/posts/hello/"`,
|
||||
`property="og:url" content="https://khosra.example/posts/hello/"`,
|
||||
`<title>Hello · Khosra</title>`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutABaseEverythingStaysRelative(t *testing.T) {
|
||||
r, err := New(nil, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := content.Parse("posts/hello.md", []byte("---\ntitle: Hello\n---\nhi\n"))
|
||||
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
if !strings.Contains(got, `rel="canonical" href="/posts/hello/"`) {
|
||||
t.Errorf("a site that declared no base still links to itself:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "og:site_name") {
|
||||
t.Error("no declared title means no site_name tag, rather than an empty one")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}}</title>
|
||||
<title>{{.Title}}{{if .Site.Title}} · {{.Site.Title}}{{end}}</title>
|
||||
<link rel="canonical" href="{{.Canonical}}">
|
||||
{{- range .Alternates}}
|
||||
<link rel="alternate" hreflang="{{.Lang}}" href="{{.URL}}">
|
||||
{{- end}}
|
||||
<meta property="og:title" content="{{.Title}}">
|
||||
<meta property="og:url" content="{{.Canonical}}">
|
||||
<meta property="og:type" content="article">
|
||||
<meta property="og:locale" content="{{.Lang}}">
|
||||
{{- if .Site.Title}}
|
||||
<meta property="og:site_name" content="{{.Site.Title}}">
|
||||
{{- end}}
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user