finish the reference theme, and the contract holes it found
The theme was supposed to need no engine work. It needed four things, which is exactly what the audit said would happen if the contract had gaps rather than the theme (ADR-0046) — a reader could not reach, from any page: another section, the tags on the page they were reading, the extras beside it, or the same page in another language. So the contract grew three fields, all additive: `.Sections` for navigation, `.Tags` with each term's listing URL, and `.ExtrasURL`, empty when a bundle has none so a theme never links a 404. Sections arrive through `Renderer.Navigation`, a callback, because sections change when content does and a copy would go stale — the nav updates on a rebuild along with everything else. One flaw only visible by looking at a rendered page: the language switcher pointed at the canonical host, because `.Alternates` went absolute for hreflang. Those are two needs, so an Alternate now carries `.URL` (absolute, for machines) and `.Path` (relative, for a link a person clicks). The theme now demonstrates every field it is given, including `.First`/`.Last`, which existed and were never rendered. Still no JavaScript, still one stylesheet. A test that asserted a page had no sequence nav was matching the inlined stylesheet rather than the markup, and now matches the element. That is the third time a loose assertion has passed for the wrong reason.
This commit is contained in:
@@ -24,6 +24,8 @@ var chrome = map[string]map[string]string{
|
||||
"position": {"en": "%s of %s", "bn": "%s / %s"},
|
||||
"extras": {"en": "Extras", "bn": "অতিরিক্ত"},
|
||||
"back-to-page": {"en": "Back to the page", "bn": "পৃষ্ঠায় ফিরুন"},
|
||||
"first": {"en": "First", "bn": "প্রথম"},
|
||||
"last": {"en": "Last", "bn": "শেষ"},
|
||||
}
|
||||
|
||||
// months are Gregorian month names per language, indexed by [time.Month]-1.
|
||||
|
||||
@@ -40,6 +40,9 @@ type Renderer struct {
|
||||
files fs.FS
|
||||
// settings are the site's declarations, constant for the life of the process.
|
||||
settings content.Settings
|
||||
// sections reports the site's sections when asked. A callback, because sections change when content does and
|
||||
// the renderer must not hold a stale copy (ADR-0049).
|
||||
sections func() []string
|
||||
// reload reparses the theme before each render, for `-dev`: editing a template should not need a restart.
|
||||
// Off in a serving build, where parsing once is the point (conventions.md).
|
||||
reload bool
|
||||
@@ -155,13 +158,19 @@ func New(siteFS fs.FS, settings content.Settings, extend func(Partial) []goldmar
|
||||
// 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{
|
||||
h := head{
|
||||
Title: title,
|
||||
Lang: lang,
|
||||
Canonical: r.absolute(canonical),
|
||||
Style: r.style,
|
||||
Site: r.settings,
|
||||
}
|
||||
if r.sections != nil {
|
||||
for _, name := range r.sections() {
|
||||
h.Sections = append(h.Sections, Item{Title: name, Key: name, URL: content.URL(name, lang)})
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// absolute is the site's own URL for a path the engine emitted, or the path itself when no base is declared.
|
||||
@@ -169,6 +178,12 @@ func (r *Renderer) absolute(path string) string {
|
||||
return content.Absolute(r.settings.Base, path)
|
||||
}
|
||||
|
||||
// Navigation tells the renderer where to find the site's sections.
|
||||
//
|
||||
// Set once at wiring time, like Reload: a page needs to offer navigation, and only the index knows which
|
||||
// sections exist. A callback rather than a slice, because content changes and a copy would go stale.
|
||||
func (r *Renderer) Navigation(sections func() []string) { r.sections = sections }
|
||||
|
||||
// Reload makes every render reparse the theme first. For `-dev` only: it trades the parse-once rule for the
|
||||
// ability to edit a template and refresh.
|
||||
func (r *Renderer) Reload() { r.reload = true }
|
||||
@@ -311,7 +326,17 @@ func (r *Renderer) Bundle(b content.Bundle, served string, variants []string, se
|
||||
Sequence: r.sequence(seq, served),
|
||||
}
|
||||
for _, l := range variants {
|
||||
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(content.URL(b.Route, l))})
|
||||
path := content.URL(b.Route, l)
|
||||
p.Alternates = append(p.Alternates, Alternate{Lang: l, URL: r.absolute(path), Path: path})
|
||||
}
|
||||
for _, tag := range b.Tags {
|
||||
p.Tags = append(p.Tags, Item{Title: tag, Key: content.TagSlug(tag), URL: content.TagURL("", content.TagSlug(tag), served, 1)})
|
||||
}
|
||||
// One Stat rather than a walk: a page only needs to know whether there is anything to link to (ADR-0047).
|
||||
if assets, hasAssets := b.Assets(); hasAssets && r.files != nil {
|
||||
if _, err := fs.Stat(r.files, path.Join(assets, content.ExtrasDir)); err == nil {
|
||||
p.ExtrasURL = content.ExtrasURL(b.Route, served, "")
|
||||
}
|
||||
}
|
||||
return r.execute(r.page, p, b.Key)
|
||||
}
|
||||
|
||||
@@ -196,3 +196,56 @@ func TestATagListingOffersBothShapesAndLetsTheThemeChoose(t *testing.T) {
|
||||
t.Errorf("every entry should be present whichever shape is used:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPageCanReachTheRestOfTheSite(t *testing.T) {
|
||||
// The four things a reader could not get to before the reference theme was finished (ADR-0049): another
|
||||
// section, this page's tags, its extras, and the same page in another language.
|
||||
fsys := fstest.MapFS{
|
||||
"content/posts/one/index.md": {Data: []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n")},
|
||||
"content/posts/one/extras/note.md": {Data: []byte("a note\n")},
|
||||
}
|
||||
r, err := New(fsys, content.Settings{Title: "Khosra"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Navigation(func() []string { return []string{"posts", "comics"} })
|
||||
b, err := content.Parse("posts/one/index.md", []byte("---\ntitle: One\ntags: [Monsoon]\n---\nx\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Path, b.Route = "content/posts/one/index.md", b.Key
|
||||
out, err := r.Bundle(b, "en", []string{"en", "bn"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(out)
|
||||
for _, want := range []string{
|
||||
`href="/posts/"`, // navigation
|
||||
`href="/comics/"`, // a section this page is not in
|
||||
`rel="tag" href="/tags/monsoon/"`, // its own tags, at the term's listing
|
||||
`href="/posts/one/extras/"`, // its extras, offered only because they exist
|
||||
`href="/bn/posts/one/" hreflang="bn"`, // the other language, as a visible relative link
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("a reader cannot reach %s:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPageOffersNoExtrasLinkWhenThereAreNone(t *testing.T) {
|
||||
// Guessing would give every page a link to a 404.
|
||||
fsys := fstest.MapFS{"content/posts/bare/index.md": {Data: []byte("---\ntitle: Bare\n---\nx\n")}}
|
||||
r, err := New(fsys, content.Settings{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := content.Parse("posts/bare/index.md", []byte("---\ntitle: Bare\n---\nx\n"))
|
||||
b.Path, b.Route = "content/posts/bare/index.md", b.Key
|
||||
out, err := r.Bundle(b, "en", []string{"en"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "/extras/") {
|
||||
t.Errorf("no extras exist, so nothing should link them:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,9 +22,32 @@
|
||||
<style>{{.Style}}</style>
|
||||
</head>
|
||||
<body>
|
||||
{{- if or .Sections .Site.Title}}
|
||||
<header>
|
||||
{{- if .Site.Title}}
|
||||
<p class="site"><a href="/">{{.Site.Title}}</a></p>
|
||||
{{- end}}
|
||||
{{- if .Sections}}
|
||||
<nav class="sections">
|
||||
{{- range .Sections}}
|
||||
<a href="{{.URL}}">{{.Title}}</a>
|
||||
{{- end}}
|
||||
</nav>
|
||||
{{- end}}
|
||||
</header>
|
||||
{{- end}}
|
||||
<main>
|
||||
{{- template "main" .}}
|
||||
</main>
|
||||
{{- if gt (len .Alternates) 1}}
|
||||
<footer>
|
||||
<nav class="languages">
|
||||
{{- range .Alternates}}
|
||||
<a href="{{.Path}}" hreflang="{{.Lang}}" lang="{{.Lang}}">{{.Lang}}</a>
|
||||
{{- end}}
|
||||
</nav>
|
||||
</footer>
|
||||
{{- end}}
|
||||
</body>
|
||||
</html>
|
||||
{{- end}}
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
<span><a href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.URL}}{{end}}</a> {{t $.Lang "position" (num $.Lang .Index) (num $.Lang .Count)}}</span>
|
||||
{{- with .Next}}<a rel="next" href="{{.URL}}">{{if .Title}}{{.Title}}{{else}}{{.Key}}{{end}}</a>{{end}}
|
||||
</nav>
|
||||
{{- if gt .Count 2}}
|
||||
<nav class="ends">
|
||||
{{- with .First}}<a href="{{.URL}}">{{t $.Lang "first"}}</a>{{end}}
|
||||
{{- with .Last}}<a href="{{.URL}}">{{t $.Lang "last"}}</a>{{end}}
|
||||
</nav>
|
||||
{{- end}}
|
||||
{{- else if .Members}}
|
||||
<nav class="sequence">
|
||||
<ol class="archive">
|
||||
@@ -21,4 +27,18 @@
|
||||
</nav>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
{{- if or .Tags .ExtrasURL}}
|
||||
<footer class="about-this-page">
|
||||
{{- if .Tags}}
|
||||
<nav class="tags">
|
||||
{{- range .Tags}}
|
||||
<a rel="tag" href="{{.URL}}">{{.Title}}</a>
|
||||
{{- end}}
|
||||
</nav>
|
||||
{{- end}}
|
||||
{{- if .ExtrasURL}}
|
||||
<p><a href="{{.ExtrasURL}}">{{t .Lang "extras"}}</a></p>
|
||||
{{- end}}
|
||||
</footer>
|
||||
{{- end}}
|
||||
{{- end}}
|
||||
|
||||
@@ -8,8 +8,18 @@ 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; }
|
||||
/* Enough structure to read the demonstration; a real theme starts over (ADR-0026). */
|
||||
header, footer { max-width: 34rem; margin: 1.5rem auto; padding: 0 1rem; font-size: 0.9em; }
|
||||
header .site { font-weight: 600; margin: 0 0 0.25rem; }
|
||||
nav.sections a, nav.languages a, nav.tags a { margin-right: 0.75rem; }
|
||||
nav.sequence, nav.ends { display: flex; gap: 1rem; justify-content: space-between; margin: 1.5rem 0; }
|
||||
ul.extras, ol.archive { padding-left: 1.25rem; }
|
||||
.kind { color: #6b6b6b; font-size: 0.85em; }
|
||||
.gallery { display: grid; grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); gap: 0.75rem; }
|
||||
.gallery figure { margin: 0; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html { color: #e8e6e1; background: #16161a; }
|
||||
a { color: #8ab4dd; }
|
||||
pre { background: #22222a; }
|
||||
.kind { color: #9a9a9a; }
|
||||
}
|
||||
|
||||
+14
-1
@@ -27,6 +27,9 @@ type head struct {
|
||||
Style template.CSS
|
||||
// Site is what the site declared about itself in site.yaml (ADR-0039). Zero when it declared nothing.
|
||||
Site content.Settings
|
||||
// Sections are the site's sections, for navigation: only .Title and .URL are set. Empty until the engine is
|
||||
// told where to find them, which cmd does at wiring time (ADR-0049).
|
||||
Sections []Item
|
||||
}
|
||||
|
||||
// Page is one bundle rendered.
|
||||
@@ -40,6 +43,11 @@ type Page struct {
|
||||
Extra map[string]any
|
||||
// Sequence is the series this page sits in, nil when it sits in none.
|
||||
Sequence *Sequence
|
||||
// Tags are this bundle's own terms, each with the URL of its listing. Empty when it carries none.
|
||||
Tags []Item
|
||||
// ExtrasURL links this bundle's supporting files, empty when it has none — so a theme can offer them
|
||||
// without guessing whether they exist (ADR-0047).
|
||||
ExtrasURL string
|
||||
}
|
||||
|
||||
// Sequence is a series as a page sees it: its members in reading order, and where this page is in them
|
||||
@@ -113,5 +121,10 @@ type Item struct {
|
||||
// Alternate is one language a bundle exists in.
|
||||
type Alternate struct {
|
||||
Lang string
|
||||
URL string
|
||||
// URL is absolute when the site declares a base, because hreflang is read by machines that resolve nothing
|
||||
// against the page.
|
||||
URL string
|
||||
// Path is the same address, root-relative — what a *visible* language link wants. Found by looking at a
|
||||
// rendered page: using URL sent a reader from a local server to the canonical host (ADR-0049).
|
||||
Path string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user