M docs/DESIGN.md => docs/DESIGN.md +16 -11
@@ 91,7 91,8 @@ authn/ # ctx.go (Caller), cookie.go (optional unified-login middleware),
storage/ # init.go (InitStore/DeleteStore via WriteEmptyRepo), dbcache.go (remotesrv.DBCache)
remoteapi/ # server.go (remotesrv assembly), interceptors.go, credsvc.go (WhoAmI grpc server)
browse/ # open.go, log.go, tables.go, diff.go — read-only doltdb over bare stores
-web/ # router.go, handlers_*.go, chrome.go, templates.go, templates/*.html
+web/ # router.go, handlers_*.go, templates.go, templates/*.html
+ # (nav/brand/login chrome comes from sr-ht-ecore/chrome)
```
Dependency direction: `core` ← all; `db`/`authn`/`storage`/`browse` mutually
@@ 179,16 180,20 @@ with both auth variants); `GET /~{user}/{db}/log?branch=&from=`;
inline JS to prefill the form, since `dolt login` appends the pubkey as a fragment);
`GET /static/*`.
-**Chrome** (web/chrome.go + templates): Go `html/template` port of core.sr.ht
-`layout.html`/`nav.html` — `<nav class="container navbar navbar-light
-navbar-expand-sm">`, brand `[sr.ht]site-name` + `<span class="text-danger">dolt</span>`,
-network list = config sections ending `.sr.ht` (minus paste/pages) linking
-`config.GetOrigin(conf, site, true)`, `active` on self; login block →
-`{meta-origin}/login?return_to=...` / logout; environment banner when
-`[sr.ht]environment != "production"`; stylesheet href globbed from
-`static-dir/main.min.*.css` at startup. CSS: `sassc -I $(ASSETS)/scss scss/main.scss`
-→ minify → `main.min.<sha256[:8]>.css` (Makefile cloned from git.sr.ht); logo.svg
-copied from core.sr.ht.
+**Chrome** (`sourcecraft.dev/bigbes/sr-ht-ecore/chrome`): the brand, the service
+switcher, the login block, the environment banner and the database listing are
+the shared package's, not ours — one `chrome.Service` built at startup from the
+instance config with our section (`dolt.sr.ht`), one `chrome.Page` per request
+embedded in each handler's view struct, and the `srht-nav` / `srht-env-banner` /
+`srht-repo-list` partials attached to every template set by `chrome.Attach`. The
+policy is ecore's: the switcher renders for authenticated viewers only, paste,
+pages and hub never appear in it, and the profile link prefers hub's `~username`
+page. Ours is the layout document itself, the page-width choice
+(`Page.ContainerClass`, `container-fluid` on the row browser) and the stylesheet
+href, globbed from `static-dir/main.min.*.css` at startup because its name
+carries a build hash. CSS: `sassc -I $(ASSETS)/scss scss/main.scss` → minify →
+`main.min.<sha256[:8]>.css` (Makefile cloned from git.sr.ht); logo.svg copied
+from core.sr.ht.
## Access matrix (core/access.go — pure, table-driven tests)
M go.mod => go.mod +1 -0
@@ 15,6 15,7 @@ require (
google.golang.org/grpc v1.79.3
gopkg.in/go-jose/go-jose.v2 v2.6.3
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
+ sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895
)
require (
M go.sum => go.sum +3 -1
@@ 427,9 427,9 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ 666,3 666,5 @@ modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
+sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us=
+sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw=
M web/csrf.go => web/csrf.go +1 -2
@@ 21,8 21,7 @@ import (
// shared unified-login cookie (SameSite handling lives in meta) and has no
// cross-origin embedding.
func (a *app) checkSameOrigin(r *http.Request) bool {
- selfOrigin := a.newBasePage(r, "").SelfOrigin
- self, err := url.Parse(selfOrigin)
+ self, err := url.Parse(a.chrome.SelfOrigin())
if err != nil || self.Host == "" {
return false
}
M web/deps.go => web/deps.go +13 -2
@@ 1,6 1,17 @@
// Package web is the HTTP layer of dolt.sr.ht: the chi router, request
-// handlers, SourceHut nav/chrome, and the html/template views for the database
-// dashboard, browse pages, settings and dolt-key management.
+// handlers, and the html/template views for the database dashboard, browse
+// pages, settings and dolt-key management.
+//
+// # The chrome is not ours
+//
+// The brand, the service switcher, the login block and the environment banner
+// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, the one copy every
+// custom service on this instance draws from. This package builds a single
+// chrome.Service at startup (newApp), asks it for a chrome.Page per request
+// (app.page), and embeds that Page in each handler's view struct so the shared
+// partials find their fields on the dot they are handed. Nothing here rebuilds
+// the switcher, re-derives a login URL or re-reads our own origin: the copies
+// that used to live in web/chrome.go are what ecore exists to have deleted.
//
// # Dependency injection
//
M web/handlers_browse.go => web/handlers_browse.go +24 -16
@@ 7,6 7,8 @@ import (
"github.com/go-chi/chi/v5"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
@@ 66,14 68,14 @@ func (a *app) handleLog(w http.ResponseWriter, r *http.Request) {
}
view := struct {
- basePage
+ chrome.Page
Repo *core.Repo
Branches []browse.Branch
Branch string
Commits []browse.CommitInfo
NextHash string
}{
- basePage: a.newBasePage(r, "Log — "+repo.OwnerName+"/"+repo.Name),
+ Page: a.page(r, "Log — "+repo.OwnerName+"/"+repo.Name),
Repo: repo,
Branches: branches,
Branch: branch,
@@ 107,13 109,13 @@ func (a *app) handleCommit(w http.ResponseWriter, r *http.Request) {
}
view := struct {
- basePage
+ chrome.Page
Repo *core.Repo
Summary *browse.CommitDiff
}{
- basePage: a.newBasePage(r, "Commit "+shortHash(hash)+" — "+repo.OwnerName+"/"+repo.Name),
- Repo: repo,
- Summary: summary,
+ Page: a.page(r, "Commit "+chrome.ShortSHA(hash)+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Summary: summary,
}
a.render(w, http.StatusOK, "commit.html", view)
}
@@ 142,17 144,17 @@ func (a *app) handleTree(w http.ResponseWriter, r *http.Request) {
}
view := struct {
- basePage
+ chrome.Page
Repo *core.Repo
Ref string
Tables []browse.TableInfo
Views []View
}{
- basePage: a.newBasePage(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
- Repo: repo,
- Ref: ref,
- Tables: tables,
- Views: applicableViews(a.views, tables),
+ Page: a.page(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ Ref: ref,
+ Tables: tables,
+ Views: applicableViews(a.views, tables),
}
a.render(w, http.StatusOK, "tree.html", view)
}
@@ 201,28 203,34 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
views = applicableViews(a.views, tables)
}
+ // PageNum and not Page: the chrome's own Page is embedded here, and the
+ // pagination counter is the page's payload, which must not shadow it.
view := struct {
- basePage
+ chrome.Page
Repo *core.Repo
Ref string
Table string
Rows *browse.RowPage
Views []View
- Page int
+ PageNum int
TotalPages int
HasPrev bool
HasNext bool
}{
- basePage: a.newBasePage(r, table+" — "+repo.OwnerName+"/"+repo.Name),
+ Page: a.page(r, table+" — "+repo.OwnerName+"/"+repo.Name),
Repo: repo,
Ref: ref,
Table: table,
Rows: rows,
Views: views,
- Page: page,
+ PageNum: page,
TotalPages: totalPages,
HasPrev: page > 1,
HasNext: page < totalPages,
}
+ // A row page is the one full-bleed screen this service has: the column count
+ // is the table's, not ours, so the centered container turns a wide table into
+ // a narrow strip with a scrollbar under it.
+ view.ContainerClass = "container-fluid"
a.render(w, http.StatusOK, "table.html", view)
}
M web/handlers_internal.go => web/handlers_internal.go +5 -4
@@ 186,7 186,7 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
authorName = caller.Username
}
if authorEmail == "" {
- authorEmail = caller.Username + "@" + hostOf(config.GetOrigin(a.cfg.Conf, serviceName, true))
+ authorEmail = caller.Username + "@" + hostOf(a.chrome.SelfOrigin())
}
if err := a.cfg.Stores.InitStore(ctx, diskPath, authorName, authorEmail); err != nil {
// InitStore self-cleans its directory; undo the metadata row too so a
@@ 199,10 199,11 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
}
// repoURL builds the external web URL for a database, e.g.
-// https://dolt.srht.bigb.es/~owner/name.
+// https://dolt.srht.bigb.es/~owner/name. The origin is the chrome's, resolved
+// once at startup and already stripped of a trailing slash, so the URL this
+// hands back to git.sr.ht is the same one the pages link to.
func (a *app) repoURL(owner, name string) string {
- origin := strings.TrimRight(config.GetOrigin(a.cfg.Conf, serviceName, true), "/")
- return fmt.Sprintf("%s/~%s/%s", origin, owner, name)
+ return fmt.Sprintf("%s/~%s/%s", a.chrome.SelfOrigin(), owner, name)
}
// hostOf returns the host authority of a URL, or the input unchanged if it does
M web/handlers_keys.go => web/handlers_keys.go +7 -5
@@ 6,12 6,14 @@ import (
"strconv"
"strings"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
// keysView is the dolt-key management page model.
type keysView struct {
- basePage
+ chrome.Page
Keys []*db.DoltKey
Error string
Notice string
@@ 24,10 26,10 @@ func (a *app) renderKeys(w http.ResponseWriter, r *http.Request, ac *authContext
return
}
view := keysView{
- basePage: a.newBasePage(r, "Dolt keys — "+serviceName),
- Keys: keys,
- Error: errMsg,
- Notice: notice,
+ Page: a.page(r, "Dolt keys — "+serviceName),
+ Keys: keys,
+ Error: errMsg,
+ Notice: notice,
}
a.render(w, status, "keys.html", view)
}
M web/handlers_repo.go => web/handlers_repo.go +42 -19
@@ 8,6 8,8 @@ import (
"github.com/go-chi/chi/v5"
"sourcecraft.dev/bigbes/sr-ht-core/config"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
@@ 22,9 24,9 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
ac, caller := callerOf(r.Context())
view := struct {
- basePage
- Repos []*core.Repo
- }{basePage: a.newBasePage(r, serviceName)}
+ chrome.Page
+ Repos chrome.RepoList
+ }{Page: a.page(r, serviceName), Repos: repoList(nil)}
if ac != nil {
repos, err := a.cfg.Repos.ListReposForDashboard(r.Context(), caller.UserID)
@@ 32,7 34,7 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
http.Error(w, "failed to list databases", http.StatusInternalServerError)
return
}
- view.Repos = repos
+ view.Repos = repoList(repos)
}
a.render(w, http.StatusOK, "index.html", view)
}
@@ 54,13 56,13 @@ type createForm struct {
func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, form createForm, errMsg string) {
view := struct {
- basePage
+ chrome.Page
Form createForm
Error string
}{
- basePage: a.newBasePage(r, "Create database — "+serviceName),
- Form: form,
- Error: errMsg,
+ Page: a.page(r, "Create database — "+serviceName),
+ Form: form,
+ Error: errMsg,
}
a.render(w, status, "create.html", view)
}
@@ 154,17 156,36 @@ func (a *app) handleUser(w http.ResponseWriter, r *http.Request) {
}
view := struct {
- basePage
+ chrome.Page
Owner string
- Repos []*core.Repo
+ Repos chrome.RepoList
}{
- basePage: a.newBasePage(r, "~"+owner+" — "+serviceName),
- Owner: owner,
- Repos: repos,
+ Page: a.page(r, "~"+owner+" — "+serviceName),
+ Owner: owner,
+ Repos: repoList(repos),
}
a.render(w, http.StatusOK, "user.html", view)
}
+// repoList adapts our databases to the shared listing partial
+// ("srht-repo-list"), which every custom service on the instance renders its
+// projects through. The Href and Title are the only service-specific part: a
+// database lives at /~owner/name and is named for it, exactly as a repository
+// is on git.sr.ht.
+func repoList(repos []*core.Repo) chrome.RepoList {
+ items := make([]chrome.ListItem, 0, len(repos))
+ for _, repo := range repos {
+ path := "/~" + repo.OwnerName + "/" + repo.Name
+ items = append(items, chrome.ListItem{
+ Href: path,
+ Title: path[1:],
+ Visibility: string(repo.Visibility),
+ Description: repo.Description,
+ })
+ }
+ return chrome.RepoList{Items: items, Empty: "No databases yet."}
+}
+
// handleOverview renders the database overview: description, visibility badge,
// branch list, latest commits, and a clone box showing both auth flows.
func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
@@ 206,7 227,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
}
view := struct {
- basePage
+ chrome.Page
Repo *core.Repo
Branches []browse.Branch
DefaultBranch string
@@ 215,21 236,23 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
CloneURL string
BrowseError string
}{
- basePage: a.newBasePage(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
+ Page: a.page(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
Repo: repo,
Branches: branches,
DefaultBranch: defBr,
Commits: commits,
Views: views,
- CloneURL: a.cloneURL(r, repo),
+ CloneURL: a.cloneURL(repo),
BrowseError: browseErr,
}
a.render(w, http.StatusOK, "overview.html", view)
}
-// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name.
-func (a *app) cloneURL(r *http.Request, repo *core.Repo) string {
- return a.newBasePage(r, "").SelfOrigin + "/~" + repo.OwnerName + "/" + repo.Name
+// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. The
+// origin is the chrome's, resolved once at startup from our config section, so
+// a clone box and a nav link can never quote two different hosts for us.
+func (a *app) cloneURL(repo *core.Repo) string {
+ return a.chrome.SelfOrigin() + "/~" + repo.OwnerName + "/" + repo.Name
}
// requireLogin returns the authenticated caller, or nil after redirecting an
M web/handlers_settings.go => web/handlers_settings.go +8 -6
@@ 8,6 8,8 @@ import (
"github.com/go-chi/chi/v5"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)
@@ 46,7 48,7 @@ func (a *app) loadRepoForAdmin(w http.ResponseWriter, r *http.Request) (repo *co
// settingsView is the settings page model.
type settingsView struct {
- basePage
+ chrome.Page
Repo *core.Repo
ACL []*db.ACLEntry
Error string
@@ 60,11 62,11 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int,
return
}
view := settingsView{
- basePage: a.newBasePage(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
- Repo: repo,
- ACL: acl,
- Error: errMsg,
- Notice: notice,
+ Page: a.page(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
+ Repo: repo,
+ ACL: acl,
+ Error: errMsg,
+ Notice: notice,
}
a.render(w, status, "settings.html", view)
}
M web/handlers_view.go => web/handlers_view.go +6 -4
@@ 6,6 6,8 @@ import (
"github.com/go-chi/chi/v5"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
@@ 76,20 78,20 @@ func (a *app) handleView(w http.ResponseWriter, r *http.Request) {
// consistent whether or not ?ref= was supplied.
branches, _ := sess.Branches(r.Context())
- page := struct {
- basePage
+ envelope := struct {
+ chrome.Page
Repo *core.Repo
Ref string
Branches []browse.Branch
Views []View
Data any
}{
- basePage: a.newBasePage(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
+ Page: a.page(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
Repo: repo,
Ref: ref,
Branches: branches,
Views: applicableViews(a.views, tables),
Data: data,
}
- a.render(w, http.StatusOK, view.Template(), page)
+ a.render(w, http.StatusOK, view.Template(), envelope)
}
M web/router.go => web/router.go +47 -10
@@ 6,21 6,49 @@ import (
"github.com/go-chi/chi/v5"
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
+
+ "sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)
-// app bundles the parsed templates, the discovered stylesheet href and the
-// injected config. Handlers are methods on *app so they share this state
-// without a global.
+// serviceName is our own service key: the config section, the JWT audience and
+// the entry the shared switcher has to recognise as the current service. One
+// constant, because a service that spelled its section differently in two
+// places would appear in the instance's navigation and fail to find itself in
+// it.
+const serviceName = "dolt.sr.ht"
+
+// app bundles the parsed templates, the shared chrome and the injected config.
+// Handlers are methods on *app so they share this state without a global.
type app struct {
cfg Config
templates templateSet
- styleHref string
+ // chrome is sr-ht-ecore's shared page frame: the brand, the service
+ // switcher, the login block and the environment banner, built once from the
+ // instance config and asked for a per-request chrome.Page (see page below).
+ chrome *chrome.Service
// views is a snapshot of the global registeredViews taken at Register time.
// Handlers read this (never the global) so tests can inject their own set.
views []View
}
+// page builds the chrome for one request: the shared frame plus the per-page
+// <title>. The caller sets any page-specific fields on its own view struct,
+// which embeds the returned chrome.Page.
+//
+// The username handed over is the resolved caller's and not whatever the cookie
+// said — an unreadable or expired cookie has already become anonymity by the
+// time a handler runs — so the nav and the page content cannot disagree about
+// who is looking.
+func (a *app) page(r *http.Request, title string) chrome.Page {
+ var username string
+ if ac := authn.CallerFromContext(r.Context()); ac != nil {
+ username = ac.Username
+ }
+ return a.chrome.Page(r, title, username)
+}
+
// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
// main) installs the config/database/cookie middleware upstream on the router
// group it passes here, then calls Register with the assembled Config.
@@ 45,16 73,25 @@ func newApp(cfg Config) (*app, error) {
cfg.Users == nil || cfg.RepoDiskPath == nil {
return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
}
+ if cfg.Conf == nil {
+ return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
+ }
templates, err := loadTemplates()
if err != nil {
return nil, err
}
+ // The switcher, the brand and the login links come from the shared config
+ // read once here; the stylesheet is discovered separately because its name
+ // carries a build hash, which no config file can know.
+ chromeSvc := chrome.NewService(cfg.Conf, serviceName)
+ chromeSvc.StyleHref = discoverStyleHref(cfg.StaticDir)
+
return &app{
cfg: cfg,
templates: templates,
- styleHref: discoverStyleHref(cfg.StaticDir),
+ chrome: chromeSvc,
// Snapshot the registry so all handlers see a stable set and tests can
// override it per-app without mutating the global.
views: append([]View{}, registeredViews...),
@@ 95,24 132,24 @@ func (a *app) mount(r chi.Router) {
// hide the existence of PRIVATE repos the caller may not browse.
func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
view := struct {
- basePage
- }{basePage: a.newBasePage(r, "Not found — "+serviceName)}
+ chrome.Page
+ }{Page: a.page(r, "Not found — "+serviceName)}
a.render(w, http.StatusNotFound, "404.html", view)
}
// forbidden renders the 403 page for a denied but non-hidden request.
func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
view := struct {
- basePage
+ chrome.Page
Message string
- }{basePage: a.newBasePage(r, "Forbidden — "+serviceName), Message: msg}
+ }{Page: a.page(r, "Forbidden — "+serviceName), Message: msg}
a.render(w, http.StatusForbidden, "403.html", view)
}
// redirectLogin sends an unauthenticated caller to meta's login, returning them
// to the current URL afterwards.
func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
- http.Redirect(w, r, a.newBasePage(r, "").LoginURL, http.StatusSeeOther)
+ http.Redirect(w, r, a.page(r, "").LoginURL, http.StatusSeeOther)
}
// loadRepoForBrowse loads the repo named by the {user}/{db} URL params and
M web/templates.go => web/templates.go +46 -58
@@ 11,6 11,8 @@ import (
"sort"
"strings"
"time"
+
+ "sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
)
//go:embed templates/*.html templates/icons/*.svg
@@ 34,11 36,14 @@ var pageTemplates = []string{
"403.html",
}
-// sharedTemplates are parsed into every page: the outer layout, the nav
-// fragment, and reusable partials (badges, pagination, etc.).
+// sharedTemplates are parsed into every page: the outer layout and this
+// service's own reusable partials (badges, tab bars). The chrome partials —
+// the brand, the switcher, the login block, the environment banner, the
+// listing — are NOT here: they come from sr-ht-ecore and are attached to every
+// set by chrome.Attach (loadTemplates), which is the copy every custom service
+// on the instance draws from.
var sharedTemplates = []string{
"templates/layout.html",
- "templates/nav.html",
"templates/partials.html",
}
@@ 57,7 62,10 @@ func loadTemplates() (templateSet, error) {
set := make(templateSet, len(pageTemplates))
for _, page := range pageTemplates {
- t := template.New("layout").Funcs(funcs)
+ t, err := chrome.Attach(template.New("layout").Funcs(funcs))
+ if err != nil {
+ return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", page, err)
+ }
files := append(append([]string{}, sharedTemplates...), "templates/"+page)
if _, err := t.ParseFS(templateFS, files...); err != nil {
return nil, fmt.Errorf("web: parse template %s: %w", page, err)
@@ 78,7 86,10 @@ func loadTemplates() (templateSet, error) {
if _, ok := set[name]; ok {
continue
}
- t := template.New("layout").Funcs(funcs)
+ t, err := chrome.Attach(template.New("layout").Funcs(funcs))
+ if err != nil {
+ return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", name, err)
+ }
files := append(append([]string{}, sharedTemplates...), "templates/"+name)
if _, err := t.ParseFS(templateFS, files...); err != nil {
return nil, fmt.Errorf("web: parse view template %s: %w", name, err)
@@ 112,53 123,38 @@ func loadIcons() (map[string]template.HTML, error) {
}
// templateFuncs is the funcmap available in every template.
+//
+// It starts from chrome.Funcs — "dict" and "shortsha", which the shared
+// partials and half this family's pages were written against — and adds this
+// service's own on top. Adding after is deliberate: a name may then be shadowed
+// on purpose rather than by accident of map ordering. Nothing here re-defines a
+// shared helper; the local copies of dict and the hash abbreviator are gone.
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
- return template.FuncMap{
- // icon renders a named inline SVG (from templates/icons). An unknown name
- // yields empty output rather than a hard error, so a missing icon never
- // crashes a page.
- "icon": func(name string) template.HTML { return icons[name] },
- // shorthash abbreviates a dolt/NBS hash to its first 8 characters, the
- // convention used everywhere commits are listed.
- "shorthash": shortHash,
- // reltime renders a humanized relative time ("3 hours ago"), no deps.
- "reltime": humanizeTime,
- // abstime renders an absolute UTC timestamp for tooltips/detail.
- "abstime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") },
- // humansize renders a byte count as a human-readable size.
- "humansize": humanizeSize,
- "upper": strings.ToUpper,
- "lower": strings.ToLower,
- // inc/dec support 1-based page arithmetic in pagination links.
- "inc": func(n int) int { return n + 1 },
- "dec": func(n int) int { return n - 1 },
- // doltHost derives the host:port a `dolt login --auth-endpoint` expects
- // from our origin URL (defaulting to :443 for https).
- "doltHost": doltHost,
- // dict builds a map from alternating key/value args, so a partial that
- // needs several fields (e.g. the "viewtabs" tab bar) can be invoked with
- // an inline context: {{template "viewtabs" (dict "Repo" .Repo ...)}}.
- "dict": dict,
- }
-}
+ m := chrome.Funcs()
-// dict builds a map[string]any from alternating key/value arguments. It powers
-// multi-field partial invocations from templates, which otherwise can pass only
-// a single pipeline value. An odd argument count or a non-string key is a
-// template authoring error and surfaces as a render error.
-func dict(kv ...any) (map[string]any, error) {
- if len(kv)%2 != 0 {
- return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv))
- }
- m := make(map[string]any, len(kv)/2)
- for i := 0; i < len(kv); i += 2 {
- k, ok := kv[i].(string)
- if !ok {
- return nil, fmt.Errorf("dict: key %d is not a string", i)
- }
- m[k] = kv[i+1]
- }
- return m, nil
+ // icon renders a named inline SVG (from templates/icons). An unknown name
+ // yields empty output rather than a hard error, so a missing icon never
+ // crashes a page.
+ m["icon"] = func(name string) template.HTML { return icons[name] }
+ // reltime renders a humanized relative time ("3 hours ago"), no deps.
+ m["reltime"] = humanizeTime
+ // abstime renders an absolute UTC timestamp for tooltips/detail.
+ m["abstime"] = func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
+ // humansize renders a byte count as a human-readable size.
+ m["humansize"] = humanizeSize
+ // "upper" and "lower" used to be here for the environment banner and the
+ // database listing's visibility label. Both are the shared chrome's markup
+ // now, and it does its own casing, so nothing in this service's templates
+ // calls them any more.
+ //
+ // inc/dec support 1-based page arithmetic in pagination links.
+ m["inc"] = func(n int) int { return n + 1 }
+ m["dec"] = func(n int) int { return n - 1 }
+ // doltHost derives the host:port a `dolt login --auth-endpoint` expects
+ // from our origin URL (defaulting to :443 for https).
+ m["doltHost"] = doltHost
+
+ return m
}
// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin
@@ 177,14 173,6 @@ func doltHost(origin string) string {
return u.Host + ":443"
}
-// shortHash returns the first 8 characters of h (or h itself if shorter).
-func shortHash(h string) string {
- if len(h) <= 8 {
- return h
- }
- return h[:8]
-}
-
// humanizeTime renders t as a coarse relative time in the past. It is a small
// self-contained helper (no new dependency) covering seconds→years.
func humanizeTime(t time.Time) string {
M web/templates/index.html => web/templates/index.html +5 -5
@@ 1,9 1,9 @@
{{define "content" -}}
-{{if .CurrentUser}}
+{{if .Username}}
<div class="row">
<div class="col-md-4">
<p>
- Welcome back, {{.CurrentUser.Username}}! This is your Dolt database
+ Welcome back, {{.Username}}! This is your Dolt database
hosting service: <code>dolt clone</code>, <code>push</code> and
<code>pull</code> over HTTPS, with a web UI for browsing branches,
commits and tables.
@@ 15,9 15,9 @@
</div>
<div class="col-md-8">
<hr class="d-md-none" />
- {{template "repoList" .Repos}}
- {{if .Repos}}
- <a href="/~{{.CurrentUser.Username}}" class="btn btn-default pull-right"
+ {{template "srht-repo-list" .Repos}}
+ {{if .Repos.Items}}
+ <a href="/~{{.Username}}" class="btn btn-default pull-right"
>More on your profile {{icon "caret-right"}}</a>
{{end}}
</div>
M web/templates/layout.html => web/templates/layout.html +18 -8
@@ 1,3 1,15 @@
+{{/*
+ The SourceHut chrome. The brand, the service switcher, the login block and the
+ environment banner are NOT rendered here: they come from sr-ht-ecore's shared
+ partials ("srht-env-banner", "srht-nav"), which every custom service on this
+ instance draws from one copy. The dot is a handler's view struct, which embeds
+ chrome.Page, so the fields those partials read promote into it.
+
+ What is left here is the document itself and the content hole. The wrapper's
+ width is .ContainerClass rather than a literal "container": the row browser
+ hands out "container-fluid" so a wide table gets the whole viewport, and every
+ other page gets the centered default chrome.Page already carries.
+*/}}
{{define "layout" -}}
<!doctype html>
<html lang="en">
@@ 6,18 18,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
- <link rel="stylesheet" href="{{.StyleHref}}">
+ {{/* Guarded rather than emitted empty: <link href=""> re-requests the page
+ it is on, which is one extra page load per page load. */}}
+ {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
</head>
<body>
- {{if .ShowEnvBanner}}
- <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
- {{.Environment | upper}} ENVIRONMENT
- </div>
- {{end}}
+ {{template "srht-env-banner" .}}
<nav class="container navbar navbar-light navbar-expand-sm">
- {{template "nav" .}}
+ {{template "srht-nav" .}}
</nav>
- <div class="container">
+ <div class="{{.ContainerClass}}">
{{template "content" .}}
</div>
</body>
M web/templates/log.html => web/templates/log.html +1 -1
@@ 21,7 21,7 @@
<tr>
<td>
<a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
- <code>{{.Hash | shorthash}}</code>
+ <code>{{.Hash | shortsha}}</code>
</a>
</td>
<td>{{.Message}}</td>
M web/templates/overview.html => web/templates/overview.html +2 -2
@@ 31,7 31,7 @@ dolt clone {{.CloneURL}}</pre>
<li>
<a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a>
{{if eq .Name $.DefaultBranch}}<span class="badge badge-secondary">default</span>{{end}}
- <code class="text-muted">{{.Head | shorthash}}</code>
+ <code class="text-muted">{{.Head | shortsha}}</code>
</li>
{{end}}
</ul>
@@ 51,7 51,7 @@ dolt clone {{.CloneURL}}</pre>
<tr>
<td>
<a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
- <code>{{.Hash | shorthash}}</code>
+ <code>{{.Hash | shortsha}}</code>
</a>
</td>
<td>{{.Message}}</td>
M web/templates/partials.html => web/templates/partials.html +5 -21
@@ 36,24 36,8 @@
</ul>
{{- end}}
-{{define "repoList" -}}
-{{if .}}
-<div class="event-list">
- {{range .}}
- <div class="event">
- <h4>
- <a href="/~{{.OwnerName}}/{{.Name}}">~{{.OwnerName}}/{{.Name}}</a>
- {{if ne (printf "%s" .Visibility) "PUBLIC"}}
- <small class="pull-right">{{lower (printf "%s" .Visibility)}}</small>
- {{end}}
- </h4>
- {{if .Description}}
- <p>{{.Description}}</p>
- {{end}}
- </div>
- {{end}}
-</div>
-{{else}}
-<p class="text-muted">No databases yet.</p>
-{{end}}
-{{- end}}
+{{/*
+ The database listing used to live here as "repoList". It is now sr-ht-ecore's
+ "srht-repo-list", whose markup this copy already matched line for line;
+ handlers build its chrome.RepoList dot (repoList in handlers_repo.go).
+*/}}
M web/templates/table.html => web/templates/table.html +3 -3
@@ 32,14 32,14 @@
<ul class="pagination">
<li class="page-item {{if not .HasPrev}}disabled{{end}}">
<a class="page-link"
- href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{dec .Page}}">
+ href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{dec .PageNum}}">
← Previous
</a>
</li>
- <li class="page-item disabled"><span class="page-link">Page {{.Page}} of {{.TotalPages}}</span></li>
+ <li class="page-item disabled"><span class="page-link">Page {{.PageNum}} of {{.TotalPages}}</span></li>
<li class="page-item {{if not .HasNext}}disabled{{end}}">
<a class="page-link"
- href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{inc .Page}}">
+ href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{inc .PageNum}}">
Next →
</a>
</li>
M web/templates/user.html => web/templates/user.html +1 -1
@@ 1,5 1,5 @@
{{define "content" -}}
<h2>{{icon "user"}} ~{{.Owner}}</h2>
<p class="text-muted">Databases owned by ~{{.Owner}}.</p>
-{{template "repoList" .Repos}}
+{{template "srht-repo-list" .Repos}}
{{- end}}
M web/web_test.go => web/web_test.go +27 -14
@@ 14,6 14,8 @@ import (
"github.com/dolthub/dolt/go/libraries/doltcore/creds"
"github.com/go-chi/chi/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
@@ 599,21 601,32 @@ func TestKeysAddDeleteAndFragmentPage(t *testing.T) {
}
}
-func TestNavRendersNetworkAndActive(t *testing.T) {
+// What the nav contains — which services appear, in what order, which one is
+// marked active, where the login link points — is sr-ht-ecore's chrome and is
+// tested there. What is ours is that every page is drawn through it at all, and
+// that the one page we ask to be full-bleed gets its own wrapper.
+func TestPagesAreDrawnThroughTheSharedChrome(t *testing.T) {
h := newHarness(t)
- rec := h.do("GET", "/", testCaller(1, "someone"), nil)
- body := rec.Body.String()
- // git.sr.ht and todo.sr.ht are network entries; paste is excluded.
- if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
- t.Fatalf("nav missing network entries; body=%s", body)
- }
- if strings.Contains(body, "https://paste.example") {
- t.Fatalf("nav included excluded paste.sr.ht")
- }
- // Our own service is active.
- if !strings.Contains(body, `nav-item active`) {
- t.Fatalf("nav missing active class for self")
- }
+ h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
+ h.browse.sess = &fakeSession{
+ branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
+ rows: &browse.RowPage{Columns: []string{"id"}, Rows: [][]string{{"1"}}, Total: 1},
+ }
+
+ dash := h.do("GET", "/", testCaller(1, "someone"), nil)
+ require.Equal(t, http.StatusOK, dash.Code)
+ body := dash.Body.String()
+ // The switcher and the brand come from the shared partials; the dolt label
+ // is the red service suffix chrome derives from our config section.
+ assert.Contains(t, body, "https://git.example", "shared switcher not rendered")
+ assert.Contains(t, body, `<span class="text-danger">dolt</span>`, "brand label not rendered")
+ // The default page width, which every page but the row browser keeps.
+ assert.Contains(t, body, `<div class="container">`)
+
+ rows := h.do("GET", "/~alice/db/table/main/things", nil, nil)
+ require.Equal(t, http.StatusOK, rows.Code)
+ assert.Contains(t, rows.Body.String(), `<div class="container-fluid">`,
+ "the row browser must be full-bleed")
}
func TestLogAndTablePages(t *testing.T) {