package web import ( "bytes" _ "embed" "html/template" "net/http" "strings" "sync" ) //go:embed layout.html var layoutHTML string // layout is the ONE base layout (SPEC-009 §4). A parse failure of a constant is // impossible; a non-nil error here would be a programming fault. var layout = template.Must(template.New("layout").Parse(layoutHTML)) // NavLister is the shell's navigation seam (SPEC-008 §7 / SPEC-009 §4): the // names of the ENABLED modules, in registration order. A disabled module's // entry is absent (D-038). *module.Registry satisfies it. Declaring the seam // here — rather than importing the registry — keeps this package a leaf. type NavLister interface { Nav() []string } var ( navMu sync.RWMutex navSrc NavLister ) // SetNav installs the process-wide navigation source. The entrypoint calls it // once at startup with the runtime module registry — the same single, explicit // wiring point as build-time discovery (D-028): the directory set is the source // of truth, and the shell reads the resulting nav. Until it is set (and in // tests) pages render without a nav. func SetNav(l NavLister) { navMu.Lock() navSrc = l navMu.Unlock() } // NavSource returns the installed nav source (nil when unset). func NavSource() NavLister { navMu.RLock() defer navMu.RUnlock() return navSrc } // Markup is page body HTML a caller has ALREADY escaped. The shell escapes // everything it interpolates except this type, so the conversion at the call // site is deliberate and greppable: escape first, then convert. type Markup = template.HTML // Attrs is an escaped attribute set for an element — the WebAuthn client's // data-* contract lives here. Every value interpolated into it MUST be escaped // by the caller. type Attrs = template.HTMLAttr // NavItem is one rendered navigation entry. type NavItem struct { // Label is the human label. Label string // Href is the target — /{GUID}/{module} inside a GUID context. Href string // Active marks the current page (aria-current). Active bool } // Page is one page rendered inside the shell. type Page struct { // Title is the document title (defaults to AppName). Title string // GUID is the identity riding the path. The nav renders only in a GUID // context; identity stays in the URL (D-001). GUID string // Guest marks an anonymous screen (enroll · login · setup · access): no nav // — there is no identity yet. Guest bool // Active is the PARAM2 module to mark as the current nav entry ("" none). Active string // Flash is a notice or error banner ("" for none). Flash string // FlashKind is "error" for an error banner; anything else is a notice. FlashKind string // CSRF is the token every form — and the WebAuthn client — submits. It is // rendered once as . CSRF string // Content is the page body: HTML the caller has ALREADY escaped. Content template.HTML // MainAttrs are extra attributes for the
element — the WebAuthn // ceremony's data-* contract. The caller MUST escape them. MainAttrs template.HTMLAttr } // layoutData is the layout's input (the unexported shape of a Page). type layoutData struct { AppName string Title string CSRF string Flash string FlashKind string Nav []NavItem HomeHref string StaticPath string Content template.HTML MainAttrs template.HTMLAttr } // Render writes a page inside the shell. The caller is responsible for // Cache-Control (the enroll/login/ceremony screens set `no-store` — F-2); the // shell sets the content type and the status. // // The page is rendered to a buffer first: a template fault never leaves a // half-written body behind a 200. func Render(w http.ResponseWriter, status int, p Page) error { data := layoutData{ AppName: AppName, Title: strings.TrimSpace(p.Title), CSRF: p.CSRF, Flash: p.Flash, FlashKind: flashKind(p.FlashKind), StaticPath: StaticPath, Content: p.Content, MainAttrs: p.MainAttrs, } if data.Title == "" { data.Title = AppName } if p.Guest || strings.TrimSpace(p.GUID) == "" { data.HomeHref = "/" } else { data.HomeHref = "/" + p.GUID + "/home" data.Nav = navItems(p.GUID, p.Active) } var buf bytes.Buffer if err := layout.Execute(&buf, data); err != nil { return err } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(status) _, err := w.Write(buf.Bytes()) return err } // flashKind normalises a banner kind for the class attribute. func flashKind(kind string) string { if strings.EqualFold(strings.TrimSpace(kind), "error") { return "error" } return "notice" } // navItems builds the nav from the installed NavLister (SPEC-008 §7: the nav is // driven by the registry, so a disabled module's entry is absent — D-038). func navItems(guid, active string) []NavItem { l := NavSource() if l == nil || guid == "" { return nil } names := l.Nav() items := make([]NavItem, 0, len(names)) for _, name := range names { name = strings.TrimSpace(name) if name == "" { continue } items = append(items, NavItem{ Label: ModuleLabel(name), Href: "/" + guid + "/" + name, Active: name == active, }) } return items } // ModuleLabel renders a module name as a nav label: the first letter upcased // and separators read as spaces ("home" -> "Home", "time_off" -> "Time off"). func ModuleLabel(name string) string { s := strings.TrimSpace(name) if s == "" { return "" } s = strings.NewReplacer("-", " ", "_", " ").Replace(s) return strings.ToUpper(s[:1]) + s[1:] }