Files
khosra/internal/web/resolve.go
T
bdeshi 420458c65e serve Atom feeds for the site, a section and a tag
Membership is a publication date, not a type declaration (ADR-0043). The parked
feed shape said "every type declared primary", which would have made feeds wait on
declared types a third time — but the thing that distinguishes a feed item is
already on disk. Pages and section landings drop out because they have no date,
which is the right reason. The parked idea stays parked with a sharper trigger:
someone wanting a *dated* bundle kept out.

Built with encoding/xml from typed structs, never a template: XML in html/template
is escaping for the wrong grammar, and that is a correctness trap rather than a
matter of taste.

A bug the evidence found, older than feeds: content.URL("", lang) built "//", so a
whole-site feed's id and alternate link were https://khosra.example// — every
entry identity wrong in every reader. The root is "/" now, with a test, and the
hand-built "/" the resolver carried for the same reason can follow later.

Two counters re-scoped rather than incremented, the same way transforms was:

Views now counts *per-bundle selection* — the thing architecture.md means by the
View layer, still at zero consumers. Output formats are not it: HTML, sitemap XML
and Atom are three functions with nothing to share, so an interface over them
would have one member and no leverage.

Effects stays at 1. A feed is generated per request like the sitemap, so it is not
a second Effect and the runner is not yet due — the next thing that writes files
off the request path is.
2026-07-31 10:54:31 +06:00

136 lines
4.5 KiB
Go

package web
import (
"strconv"
"strings"
"khosra/internal/content"
)
// resolution is what a request path means: which bundle key, in which language, or where to send the
// client instead.
type resolution struct {
key string
lang string
// page is 1 for a bundle or the first listing page, higher for /page/N/.
page int
// tag is a tag slug when the path named a tag listing; key then holds the section, or "" for the
// global listing.
tag string
// redirect is the canonical path when the request named a non-canonical one. Non-empty means answer
// with a permanent redirect and nothing else.
redirect string
// feed means the request named a feed of whatever scope key and tag describe (ADR-0043).
feed bool
}
// resolve maps a request path to a bundle key and language.
//
// Two cases, which is why this is a resolver rather than an inline branch: the default locale lives at
// the root of the URL space, every other language under a prefix on the same key (ADR-0009). `/en/…` is
// never a live URL — it redirects to the root form so the space cannot fork. The canonical form ends in
// a slash (ADR-0008).
//
// A language prefix wins over a section of the same name, so a site with Bengali content cannot also
// have a section called `bn` (content-model.md).
func resolve(path string, site *content.Site) (resolution, bool) {
if path == "" || path[0] != '/' {
return resolution{}, false
}
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return resolution{}, false
}
lang := content.DefaultLang
key := content.Normalise(trimmed)
if head, rest, found := strings.Cut(key, "/"); found && head != "" {
switch {
case head == content.DefaultLang:
// /en/… is a second spelling of the root form; send the client to the real one.
return resolution{redirect: content.URL(rest, content.DefaultLang)}, true
case site.HasLang(head):
lang, key = head, rest
}
} else if key == content.DefaultLang {
return resolution{redirect: "/"}, true
}
// A trailing feed.xml names a feed of the scope before it. It is a file rather than a page, so none of
// the trailing-slash canonicalisation below applies to it (ADR-0043).
if rest, isFeed := cutFeed(key); isFeed {
if tag, section, isTag := cutTag(rest); isTag {
return resolution{key: section, tag: tag, lang: lang, feed: true}, true
}
return resolution{key: rest, lang: lang, feed: true}, true
}
page := 1
// A trailing /page/N/ is pagination, not part of the key (ADR-0028). Page one is the bare listing
// URL, so /page/1/ is a second spelling and redirects.
if rest, n, isPaged := cutPage(key); isPaged {
if n == 1 {
return resolution{redirect: content.PageURL(rest, lang, 1)}, true
}
key, page = rest, n
}
// tags/<term> at the top level, or <section>/tags/<term> inside one (ADR-0018). Recognised by shape
// here, so URL knowledge stays in the resolver.
tag, section, isTag := cutTag(key)
if !strings.HasSuffix(path, "/") {
if isTag {
return resolution{key: section, tag: tag, lang: lang, page: page,
redirect: content.TagURL(section, tag, lang, page)}, true
}
return resolution{key: key, lang: lang, page: page, redirect: content.PageURL(key, lang, page)}, true
}
if isTag {
return resolution{key: section, tag: tag, lang: lang, page: page}, true
}
return resolution{key: key, lang: lang, page: page}, true
}
// cutFeed strips a trailing feed.xml, reporting whether one was there. What remains is the scope: empty for
// the whole site, a section, or a tag path.
func cutFeed(key string) (rest string, ok bool) {
if key == feedFile {
return "", true
}
if trimmed, found := strings.CutSuffix(key, "/"+feedFile); found {
return trimmed, true
}
return key, false
}
// cutTag splits a tag listing key into its term and the section it is narrowed to.
func cutTag(key string) (tag, section string, ok bool) {
if rest, found := strings.CutPrefix(key, content.TagsSegment+"/"); found {
return rest, "", rest != ""
}
if i := strings.Index(key, "/"+content.TagsSegment+"/"); i >= 0 {
term := key[i+len(content.TagsSegment)+2:]
return term, key[:i], term != ""
}
return "", "", false
}
// cutPage strips a trailing "page/N" off a key, reporting the page number.
func cutPage(key string) (rest string, page int, ok bool) {
i := strings.LastIndex(key, "/")
if i < 0 {
return key, 1, false
}
n, err := strconv.Atoi(key[i+1:])
if err != nil || n < 1 {
return key, 1, false
}
switch base := key[:i]; {
case base == "page":
return "", n, true
case strings.HasSuffix(base, "/page"):
return strings.TrimSuffix(base, "/page"), n, true
}
return key, 1, false
}