~bigbes/sr-ht-ecore

ref: 996577debafad52175a58dad9819376a0ba1887c sr-ht-ecore/chrome/chrome.go -rw-r--r-- 9.6 KiB
996577de — Eugene Blikh pages: the shared page set, buffered render and error page 10 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
// Package chrome is the shared page chrome for the custom services of a
// self-hosted SourceHut instance (compare, spec, dolt, cover, bench, ...).
//
// Every one of those services renders the same top strip: the brand (circle
// icon + site name + red service label), the service switcher derived from the
// shared config.ini, and the login box against meta.sr.ht's unified login.
// Before this package each service carried its own copy of the nav-building
// code and markup, and the copies drifted (hardcoded brand labels, string vs
// const active checks, divergent hub handling). This package is the one copy.
//
// Usage:
//
//	svc := chrome.NewService(conf, "compare.sr.ht")
//	svc.StyleHref = cssHref            // after discovering the hashed stylesheet
//	page := svc.Page(r, "My title", username)
//
// and in the layout, after chrome.Attach(t) has parsed the shared partials:
//
//	{{template "srht-env-banner" .}}
//	<nav class="container navbar navbar-light navbar-expand-sm">
//	  {{template "srht-nav" .}}
//	</nav>
//
// The template dot must expose the Page fields — either a Page itself or a
// service view struct that embeds it (promoted fields resolve in templates).
//
// Unified policy decisions, deliberately baked in rather than parameterized:
// the switcher renders only for authenticated viewers; paste, pages and hub
// never appear in it (hub is the brand's business); the profile link prefers
// hub's ~username page when hub is configured; the brand is always circle +
// site name + red service label, with the name linking to hub and the label to
// the service's own root.
package chrome

import (
	"net/http"
	"net/url"
	"sort"
	"strings"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
)

// navCanonical is the SourceHut service-switcher order, mirroring upstream
// core.sr.ht's _network_order. Services not listed here (including the custom
// ones) sort alphabetically after these.
var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}

// navExcluded are service sections that never appear in the switcher: paste
// and pages have no top-level UI worth linking, and hub is not a sibling
// service but the network's front page.
var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}

// NavItem is one entry in the service switcher (or a service-specific extra).
type NavItem struct {
	Name   string // link text, e.g. "git"
	Origin string // href
	Active bool   // highlights the current service
}

// BuildNav derives the service switcher from the shared config: every section
// whose name ends in ".sr.ht" (with a configured origin) except the excluded
// ones, ordered canonically then alphabetically, with the section named by
// active marked as the current service.
//
// The ".sr.ht" suffix is the whole membership rule — it is what core.sr.ht's
// own _network does, and it is why a custom service's section must be named
// literally "<name>.sr.ht" no matter what host it is served from.
func BuildNav(conf ini.File, active string) []NavItem {
	var items []NavItem
	for section := range conf {
		if !strings.HasSuffix(section, ".sr.ht") {
			continue
		}
		short := strings.TrimSuffix(section, ".sr.ht")
		if navExcluded[short] {
			continue
		}
		origin := config.GetOrigin(conf, section, true)
		if origin == "" {
			continue
		}
		items = append(items, NavItem{
			Name:   short,
			Origin: origin,
			Active: section == active,
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
		if ci != cj {
			return ci < cj
		}
		return items[i].Name < items[j].Name
	})
	return items
}

// canonIndex returns a service's position in navCanonical, or a sentinel past
// the end for services that are not canonically ordered.
func canonIndex(name string) int {
	for i, n := range navCanonical {
		if n == name {
			return i
		}
	}
	return len(navCanonical)
}

// Page is the chrome every rendered page shares. Services embed it in their
// own view struct and add page payload (and service-specific chrome fields)
// next to it.
//
// Embedding names the field Page, so a view struct that wants "Page" for its
// own payload — a pagination counter, most often — has to rename that field
// (PageNum, say). The collision is a compile error, not a silent shadow.
type Page struct {
	Title     string
	SiteName  string
	SiteLabel string // red brand suffix: the service's short name

	Nav      []NavItem
	ExtraNav []NavItem // service-specific entries appended after the switcher

	Username    string // "" for an anonymous viewer
	LoginURL    string // meta login with return_to back to the current URL
	LogoutURL   string // meta logout with return_to to this service's root
	RegisterURL string
	ProfileURL  string // hub's ~username page when hub is configured, else meta profile

	MetaOrigin string
	SelfOrigin string
	HubOrigin  string

	StyleHref string // "" when the binary was built without a stylesheet

	Environment string // uppercased; banner text
	ShowBanner  bool   // true outside production

	// ContainerClass selects the width of the page's content wrapper: the
	// centered Bootstrap "container" by default; services override it to
	// "container-fluid" for full-bleed pages (diff views, annotated source).
	ContainerClass string
}

// ListItem is one project in a listing — a repository, a database, a space.
// Title is the display name ("~owner/name"); Visibility is the service's
// literal enum value ("PUBLIC"/"UNLISTED"/"PRIVATE", "" to render nothing —
// the srht-repo-list partial shows it lowercase, non-public only).
type ListItem struct {
	Href        string
	Title       string
	Visibility  string
	Description string
}

// RepoList is the dot for the srht-repo-list partial: the items, and the
// muted text shown when there are none.
type RepoList struct {
	Items []ListItem
	Empty string
}

// Service is the static half of the chrome, built once at startup. The
// exported fields may be adjusted between NewService and the first Page call
// (they are read, never written, by Page).
type Service struct {
	// Section is the literal config section, e.g. "compare.sr.ht".
	Section string
	// StyleHref is the href of the built stylesheet (the hashed
	// main.min.<sha>.css); the zero value renders a bare page rather than
	// failing, matching how the services degrade without CSS.
	StyleHref string
	// ExtraNav holds service-specific switcher entries (e.g. a /tokens link),
	// rendered after the shared network entries, for authenticated viewers.
	ExtraNav []NavItem

	siteName    string
	environment string
	selfOrigin  string
	metaOrigin  string
	hubOrigin   string
	nav         []NavItem
}

// NewService reads the shared config once and caches everything Page needs.
// section must be this service's literal config section name.
func NewService(conf ini.File, section string) *Service {
	env := config.GetString(conf, "sr.ht", "environment", "development")
	return &Service{
		Section:     section,
		siteName:    config.GetString(conf, "sr.ht", "site-name", "sr.ht"),
		environment: env,
		selfOrigin:  strings.TrimRight(config.GetOrigin(conf, section, true), "/"),
		metaOrigin:  strings.TrimRight(config.GetOrigin(conf, "meta.sr.ht", true), "/"),
		hubOrigin:   strings.TrimRight(config.GetOrigin(conf, "hub.sr.ht", true), "/"),
		nav:         BuildNav(conf, section),
	}
}

// SelfOrigin returns the service's own external origin, as resolved from the
// config section given to NewService.
func (s *Service) SelfOrigin() string { return s.selfOrigin }

// MetaOrigin returns meta.sr.ht's external origin.
func (s *Service) MetaOrigin() string { return s.metaOrigin }

// HubOrigin returns hub.sr.ht's external origin, or "" when the instance has
// no hub.
func (s *Service) HubOrigin() string { return s.hubOrigin }

// SiteName returns the instance's brand text.
func (s *Service) SiteName() string { return s.siteName }

// Environment returns the configured environment as written in the config
// (lowercase); Page uppercases it for the banner.
func (s *Service) Environment() string { return s.environment }

// LoginURLFor is meta.sr.ht's login with return_to pointing back at the URL
// being served — the same link the nav's "Log in" carries. Exported for the
// handlers that gate a page behind login and only need somewhere to redirect,
// so they do not have to build a whole Page to read one field off it.
func (s *Service) LoginURLFor(r *http.Request) string {
	return s.metaOrigin + "/login?return_to=" + url.QueryEscape(s.selfOrigin+r.URL.RequestURI())
}

// Page builds the chrome for one request. Login return_to is the current full
// URL (so the viewer lands back where they were); logout return_to is this
// service's origin. username is the caller's *authoritative* identity — pass
// "" for viewers whose cookie grants nothing, and the nav offers login.
func (s *Service) Page(r *http.Request, title, username string) Page {
	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username
	}

	return Page{
		Title:          title,
		SiteName:       s.siteName,
		SiteLabel:      strings.TrimSuffix(s.Section, ".sr.ht"),
		Nav:            s.nav,
		ExtraNav:       s.ExtraNav,
		Username:       username,
		LoginURL:       s.LoginURLFor(r),
		LogoutURL:      s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.selfOrigin),
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,
		MetaOrigin:     s.metaOrigin,
		SelfOrigin:     s.selfOrigin,
		HubOrigin:      s.hubOrigin,
		StyleHref:      s.StyleHref,
		Environment:    strings.ToUpper(s.environment),
		ShowBanner:     s.environment != "" && s.environment != "production",
		ContainerClass: "container",
	}
}