The demo writes a whole site root that exercises every feature: two languages with a fallback, a series with ordered chapters, a gallery, a figure, an include, extras, tags across sections, a slug with an alias, an undated page, a draft, a template override, static files and site.yaml. It generates its filler rather than copying stored files, because nothing in this repository is content (ADR-0011) — and that makes it a test of the engine rather than a fixture: anything khosra can do that the demo cannot express is a gap. Two things found by generating and then serving it, which is the whole point: `khosra check` reported the demo's own series as mixing ordered and unordered members. It was right — the chapter bodies *described* `order: 10` while the frontmatter never carried it. The checker caught its own author. And `/` was a **404**. ADR-0008 leaves the root engine-owned, which is right, but "engine-owned" was never given an answer, so a visitor to the site's own address got nothing. The root now lists every bundle, newest first, paginated like any other listing, and 404s only when nothing is published. A hand-written home page stays a separate decision, recorded as such. Verified end to end: 23 files written, 12 bundles, 12 derivatives, `check` clean, and every URL the demo promises answers — including the alias redirecting, the draft hidden, and the front page rendering through the site's *own* template override.
171 lines
6.1 KiB
Go
171 lines
6.1 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
|
|
}
|
|
// The root is a listing of everything, not a miss: the engine owns "/" (ADR-0008), so it answers with the
|
|
// one thing it can — every bundle, newest first (ADR-0050).
|
|
trimmed := strings.Trim(path, "/")
|
|
if trimmed == "" {
|
|
return resolution{lang: content.DefaultLang, page: 1}, true
|
|
}
|
|
|
|
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
|
|
}
|