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 // extras means the request named a bundle's supporting files; entry is the one it picked, or "" for the // listing (ADR-0047). extras bool entry string } // 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, key, redirect := cutLang(content.Normalise(trimmed), site) if redirect != "" { return resolution{redirect: 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 } // An `extras` segment inside a key names a bundle's supporting files, and everything after it is one // entry's path — which may contain slashes, so it is taken whole (ADR-0047). if bundle, entry, isExtras := cutExtras(key); isExtras { return resolution{key: bundle, entry: entry, lang: lang, extras: 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/ at the top level, or
/tags/ 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 } // cutLang splits a leading language prefix off a key. // // A 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). `/en/…` is never live: it is a second spelling of the root form, and the // second return value is the redirect that collapses it (ADR-0009). func cutLang(key string, site *content.Site) (lang, rest, redirect string) { if head, after, found := strings.Cut(key, "/"); found && head != "" { switch { case head == content.DefaultLang: return "", "", content.URL(after, content.DefaultLang) case site.HasLang(head): return head, after, "" } } else if key == content.DefaultLang { return "", "", "/" } return content.DefaultLang, key, "" } // cutExtras splits a key at its `extras` segment, into the bundle before it and the entry path after. func cutExtras(key string) (bundle, entry string, ok bool) { const marker = "/" + content.ExtrasDir switch { case strings.HasSuffix(key, marker): return strings.TrimSuffix(key, marker), "", true case strings.Contains(key, marker+"/"): before, after, _ := strings.Cut(key, marker+"/") return before, after, true } return "", "", false } // 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 }