Files
khosra/internal/web/resolve.go
T
bdeshi 37a3fa6ce6 publish a bundle's extras as a browsable tree
Re-adopts the parked extras entry as ADR-0047: `extras/` inside a bundle is skipped
by the scanner entirely, so a `.md` in there is an asset with no identity and no URL
of its own. The engine enumerates the tree, sorts it by path, classifies by
extension, renders markdown and text, and offers everything else as bytes. One route
with two behaviours — `…/extras/{path}` selects an entry, `?raw` returns the file.

Almost everything it needed already existed, which is the sign the model was right:
the scanner had a directory exclusion, `Assets()` knew which bundles own a
directory, and `Lookup` already decided visibility — so a draft hides its extras
with no new check. A test proves that, including `?raw`.

Two deviations from the parked shape, both because the shape was written before the
code. The directory name is fixed rather than a cascade key, since nothing reads a
section-level setting yet. And an entry is resolved against the *enumeration* rather
than the filesystem: not being in the listing is a stronger answer than os.Root
refusing a path, and cheaper.

Selecting is a link and a full page. No JavaScript is involved, and a
sidebar-and-pane layout is the theme's business — which is the layer rule applied
before writing the feature rather than after.

Three size warnings fired as a result and were fixed by splitting at seams, not by
sharding: render.go gave up its type declarations to view.go, which is the theme
contract in Go and nothing else; serve() split into a dispatcher and serveBundle;
resolve() gave up its language-prefix step to cutLang.
2026-08-01 02:23:37 +06:00

169 lines
5.9 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
// 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/<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
}
// 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
}