profile: about text and free-form links !106

merged merged by cmc on 2026-08-28 18:54 UTC · krz/gitbay:profile-about-links into main

10 files changed, +301 −44

Layout: unified · split

cmd/gitbay/main.go +4 −2
@@ -66,7 +66,8 @@ func newRoot() *cobra.Command {
6666 orgCmd(),
6767 group("profile", "user and org profiles",
6868 pass("show", "show a profile: [name]", passOpts{server: []string{"profile", "show"}}),
69 pass("set", "set your profile: [--description d] [--website url]", passOpts{server: []string{"profile", "set"}}),
69 pass("set", "set your profile: [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
70 passOpts{server: []string{"profile", "set"}, stdinOK: true}),
7071 ),
7172 webhookCmd(),
7273 remoteCmd(),
@@ -456,7 +457,8 @@ func orgCmd() *cobra.Command {
456457 pass("show", "show an organization and its members", passOpts{server: []string{"org", "show"}}),
457458 pass("rename", "rename an organization: <old> <new>", passOpts{server: []string{"org", "rename"}}),
458459 pass("delete", "delete an empty organization (--yes)", passOpts{server: []string{"org", "delete"}}),
459 pass("profile", "show or set an org profile: <org> [--description d] [--website url]", passOpts{server: []string{"org", "profile"}}),
460 pass("profile", "show or set an org profile: <org> [--description d] [--website url] [--about t|--file -] [--about-format md|org] [--link label|url]...",
461 passOpts{server: []string{"org", "profile"}, stdinOK: true}),
460462 group("members", "manage members",
461463 pass("add", "add or update a member: <org> <user> [--role member|admin]", passOpts{server: []string{"org", "members", "add"}}),
462464 pass("remove", "remove a member: <org> <user>", passOpts{server: []string{"org", "members", "remove"}}),
e2e/profile_test.go +86 −1
@@ -96,11 +96,96 @@ func TestOwnerProfiles(t *testing.T) {
9696 t.Fatalf("org profile show: %s", out)
9797 }
9898
99 // Owner pages render description and website link.
99 // About: long-form markdown, set inline or piped, rendered on the page.
100 if _, errOut, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n",
101 "profile", "set", "--file", "-"); code != 0 {
102 t.Fatalf("about from stdin: %s", errOut)
103 }
104 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
105 if !strings.Contains(out, "I maintain *small tools*.") {
106 t.Fatalf("about not stored: %s", out)
107 }
108 if !strings.Contains(out, "tinkerer") {
109 t.Fatalf("about clobbered the description: %s", out)
110 }
111
112 // Org-mode about: the stored format picks the renderer.
113 if _, errOut, code := inst.ssh(t, aliceKey, "* Tools\n\nI maintain /small tools/.\n",
114 "profile", "set", "--file", "-", "--about-format", "org"); code != 0 {
115 t.Fatalf("org about: %s", errOut)
116 }
117 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
118 if !strings.Contains(out, `"about_format":"org"`) {
119 t.Fatalf("about format not stored: %s", out)
120 }
121 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about-format", "rst"); code != 2 {
122 t.Fatal("unknown about format accepted")
123 }
124 // Org emphasis parsed, not left as literal slashes the way the
125 // markdown renderer would.
100126 status, body := inst.get(t, "/alice")
127 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
128 t.Fatalf("org about not rendered as org: %s", body)
129 }
130
131 // Back to markdown for the rest of the checks.
132 if _, _, code := inst.ssh(t, aliceKey, "# Hello\n\nI maintain *small tools*.\n",
133 "profile", "set", "--file", "-", "--about-format", "md"); code != 0 {
134 t.Fatal("markdown about")
135 }
136
137 // Links: free-form, labelled or bare, capped, cleared by an empty one.
138 if _, errOut, code := inst.ssh(t, aliceKey, "", "profile", "set",
139 "--link", "'Mastodon|https://fosstodon.example/@alice'",
140 "--link", "https://alice.example/now"); code != 0 {
141 t.Fatalf("links: %s", errOut)
142 }
143 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
144 if !strings.Contains(out, `"label":"Mastodon"`) ||
145 !strings.Contains(out, `"url":"https://fosstodon.example/@alice"`) ||
146 !strings.Contains(out, `"url":"https://alice.example/now"`) {
147 t.Fatalf("links not stored: %s", out)
148 }
149 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "'x|gopher://nope'"); code != 2 {
150 t.Fatal("bad link scheme accepted")
151 }
152 sixth := []string{"profile", "set"}
153 for i := 0; i < 6; i++ {
154 sixth = append(sixth, "--link", "https://example.org/"+string(rune('a'+i)))
155 }
156 if _, _, code := inst.ssh(t, aliceKey, "", sixth...); code != 2 {
157 t.Fatal("more than five links accepted")
158 }
159
160 // Owner pages render description and website link.
161 status, body = inst.get(t, "/alice")
101162 if status != 200 || !strings.Contains(body, "tinkerer") {
102163 t.Fatalf("user page profile: %d", status)
103164 }
165 // About renders as markdown between the header and the activity graph;
166 // links render as chips.
167 if !strings.Contains(body, "<em>small tools</em>") {
168 t.Fatalf("about not rendered: %s", body)
169 }
170 if !strings.Contains(body, `href="https://fosstodon.example/@alice"`) ||
171 !strings.Contains(body, ">Mastodon<") {
172 t.Fatalf("links not rendered: %s", body)
173 }
174 if strings.Index(body, "<em>small tools</em>") > strings.Index(body, `class="activity"`) {
175 t.Error("about renders below the activity graph")
176 }
177
178 // Clearing works the same way as the other fields.
179 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--link", "''"); code != 0 {
180 t.Fatal("clear links failed")
181 }
182 if _, _, code := inst.ssh(t, aliceKey, "", "profile", "set", "--about", "''"); code != 0 {
183 t.Fatal("clear about failed")
184 }
185 out, _, _ = inst.ssh(t, aliceKey, "", "profile", "show", "alice", "--json")
186 if strings.Contains(out, "fosstodon") || strings.Contains(out, "small tools") {
187 t.Fatalf("about or links not cleared: %s", out)
188 }
104189 status, body = inst.get(t, "/workshop")
105190 if status != 200 || !strings.Contains(body, "where things get made") ||
106191 !strings.Contains(body, `href="https://workshop.example"`) {
internal/control/migrate.go +2 −1
@@ -157,7 +157,8 @@ func runAccountImportBundle(c *Ctx, args []string) int {
157157 return c.fail(protocol.ExitUsage, "unsupported bundle %q (want %s)", b.Bundle, bundleVersion)
158158 }
159159
160 if b.Profile.Description != "" || b.Profile.Website != "" {
160 if b.Profile.Description != "" || b.Profile.Website != "" ||
161 b.Profile.About != "" || len(b.Profile.Links) > 0 {
161162 c.Store.SetOwnerProfile("user", c.User.ID, b.Profile)
162163 }
163164 for _, addr := range b.Emails {
internal/control/profile.go +138 −31
@@ -18,32 +18,105 @@ func init() {
1818 register(Command{Path: []string{"profile", "show"},
1919 Summary: "show a user's or org's profile: profile show [name]", ReadOnly: true, Run: runProfileShow})
2020 register(Command{Path: []string{"profile", "set"},
21 Summary: "set your profile: profile set [--description <d>] [--website <url>] ('' clears)", Run: runProfileSet})
21 Summary: "set your profile: profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]... ('' clears)", ReadsStdin: true, Run: runProfileSet})
2222 register(Command{Path: []string{"org", "profile"},
23 Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>]", Run: runOrgProfile})
23 Summary: "show or set an org's profile: org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...", ReadsStdin: true, Run: runOrgProfile})
2424}
2525
26// parseProfileFlags pulls --description/--website out of args; a flag given
27// with an empty value clears the field, an absent flag leaves it untouched.
28func parseProfileFlags(args []string) (rest []string, desc, site *string, err error) {
26// maxProfileLinks caps the free-form link list. A profile is a header,
27// not a linktree.
28const maxProfileLinks = 5
29
30// profileEdit is the set of profile fields a command may change. A nil
31// field is left alone; an empty value clears it.
32type profileEdit struct {
33 Description *string
34 Website *string
35 About *string
36 AboutFormat *string
37 Links *[]store.ProfileLink
38}
39
40func (e profileEdit) empty() bool {
41 return e.Description == nil && e.Website == nil && e.About == nil &&
42 e.AboutFormat == nil && e.Links == nil
43}
44
45// parseProfileFlags pulls the profile flags out of args. --about takes
46// inline text or reads stdin via --file -; --link repeats, and a single
47// empty --link clears the list.
48func parseProfileFlags(c *Ctx, args []string) (rest []string, e profileEdit, err error) {
49 about, file := "", ""
50 sawAbout := false
51 var links []store.ProfileLink
2952 for i := 0; i < len(args); i++ {
3053 switch args[i] {
31 case "--description", "--website":
54 case "--description", "--website", "--about", "--about-format", "--file", "--link":
3255 if i+1 >= len(args) {
33 return nil, nil, nil, fmt.Errorf("%s requires a value", args[i])
56 return nil, e, fmt.Errorf("%s requires a value", args[i])
3457 }
3558 v := args[i+1]
36 if args[i] == "--description" {
37 desc = &v
38 } else {
39 site = &v
59 switch args[i] {
60 case "--description":
61 e.Description = &v
62 case "--website":
63 e.Website = &v
64 case "--about":
65 about, sawAbout = v, true
66 case "--about-format":
67 e.AboutFormat = &v
68 case "--file":
69 file, sawAbout = v, true
70 case "--link":
71 if v == "" {
72 links = nil
73 e.Links = &links
74 break
75 }
76 l, lerr := parseProfileLink(v)
77 if lerr != nil {
78 return nil, e, lerr
79 }
80 links = append(links, l)
81 e.Links = &links
4082 }
4183 i++
4284 default:
4385 rest = append(rest, args[i])
4486 }
4587 }
46 return rest, desc, site, nil
88 if sawAbout {
89 body, berr := bodyFrom(c, about, file)
90 if berr != nil {
91 return nil, e, berr
92 }
93 e.About = &body
94 }
95 if len(links) > maxProfileLinks {
96 return nil, e, fmt.Errorf("at most %d links", maxProfileLinks)
97 }
98 return rest, e, nil
99}
100
101// parseProfileLink splits "label|url"; without a separator the whole
102// value is the URL.
103func parseProfileLink(v string) (store.ProfileLink, error) {
104 label, url, ok := strings.Cut(v, "|")
105 if !ok {
106 label, url = "", v
107 }
108 label = strings.TrimSpace(label)
109 if len(label) > 32 {
110 label = label[:32]
111 }
112 url = strings.TrimSpace(url)
113 if url == "" {
114 return store.ProfileLink{}, errors.New("a link needs a url")
115 }
116 if !strings.HasPrefix(url, "https://") && !strings.HasPrefix(url, "http://") {
117 return store.ProfileLink{}, errors.New("link url must start with https:// or http://")
118 }
119 return store.ProfileLink{Label: label, URL: url}, nil
47120}
48121
49122func validateWebsite(url string) error {
@@ -53,21 +126,34 @@ func validateWebsite(url string) error {
53126 return errors.New("website must start with https:// or http://")
54127}
55128
56func applyProfile(p store.Profile, desc, site *string) (store.Profile, error) {
57 if desc != nil {
58 d, _, _ := strings.Cut(strings.TrimSpace(*desc), "\n")
129func applyProfile(p store.Profile, e profileEdit) (store.Profile, error) {
130 if e.Description != nil {
131 d, _, _ := strings.Cut(strings.TrimSpace(*e.Description), "\n")
59132 if len(d) > 256 {
60133 d = d[:256]
61134 }
62135 p.Description = d
63136 }
64 if site != nil {
65 s := strings.TrimSpace(*site)
137 if e.Website != nil {
138 s := strings.TrimSpace(*e.Website)
66139 if err := validateWebsite(s); err != nil {
67140 return p, err
68141 }
69142 p.Website = s
70143 }
144 if e.About != nil {
145 p.About = strings.TrimSpace(*e.About)
146 }
147 if e.AboutFormat != nil {
148 f := strings.TrimSpace(*e.AboutFormat)
149 if f != "md" && f != "org" {
150 return p, errors.New("about format must be md or org")
151 }
152 p.AboutFormat = f
153 }
154 if e.Links != nil {
155 p.Links = *e.Links
156 }
71157 return p, nil
72158}
73159
@@ -76,6 +162,11 @@ type profileOut struct {
76162 Kind string `json:"kind"`
77163 Description string `json:"description,omitempty"`
78164 Website string `json:"website,omitempty"`
165 // About is long-form markdown, rendered by the web between the
166 // header and the activity graph.
167 About string `json:"about,omitempty"`
168 AboutFormat string `json:"about_format,omitempty"`
169 Links []store.ProfileLink `json:"links,omitempty"`
79170 // The rest is what a profile page shows: who they work with, what
80171 // they own that you can see, and how active they have been. The web
81172 // read these straight out of the store, which kept them off every
@@ -124,6 +215,9 @@ func emitProfile(c *Ctx, d profileOut) int {
124215 if d.Website != "" {
125216 fmt.Fprintf(w, "%s\n", d.Website)
126217 }
218 for _, l := range d.Links {
219 fmt.Fprintf(w, "link\t%s\t%s\n", l.Label, l.URL)
220 }
127221 for _, m := range d.Orgs {
128222 fmt.Fprintf(w, "org\t%s\t%s\n", m.Name, m.Role)
129223 }
@@ -136,6 +230,9 @@ func emitProfile(c *Ctx, d profileOut) int {
136230 if d.ActivityTotal > 0 {
137231 fmt.Fprintf(w, "activity\t%d in the last year\n", d.ActivityTotal)
138232 }
233 if d.About != "" {
234 fmt.Fprintf(w, "\n%s\n", d.About)
235 }
139236 })
140237}
141238
@@ -159,7 +256,7 @@ func runProfileShow(c *Ctx, args []string) int {
159256 return c.fail(protocol.ExitFailure, "%v", err)
160257 }
161258 d := profileOut{Name: name, Kind: kind, Description: p.Description, Website: p.Website,
162 Repos: []profileRepo{}}
259 About: p.About, AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}}
163260
164261 // Who they work with. Both lists are public on a profile — the web
165262 // has always shown them — and neither exposes anything a member
@@ -226,18 +323,22 @@ func runProfileShow(c *Ctx, args []string) int {
226323}
227324
228325func runProfileSet(c *Ctx, args []string) int {
229 rest, desc, site, err := parseProfileFlags(args)
230 if err != nil || len(rest) != 0 {
231 return c.fail(protocol.ExitUsage, "usage: profile set [--description <d>] [--website <url>]")
326 rest, e, err := parseProfileFlags(c, args)
327 if err != nil {
328 return c.fail(protocol.ExitUsage, "%v", err)
329 }
330 if len(rest) != 0 {
331 return c.fail(protocol.ExitUsage,
332 "usage: profile set [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
232333 }
233 if desc == nil && site == nil {
234 return c.fail(protocol.ExitUsage, "nothing to set: pass --description and/or --website")
334 if e.empty() {
335 return c.fail(protocol.ExitUsage, "nothing to set: pass --description, --website, --about and/or --link")
235336 }
236337 p, err := c.Store.OwnerProfile("user", c.User.ID)
237338 if err != nil {
238339 return c.fail(protocol.ExitFailure, "%v", err)
239340 }
240 p, err = applyProfile(p, desc, site)
341 p, err = applyProfile(p, e)
241342 if err != nil {
242343 return c.fail(protocol.ExitUsage, "%v", err)
243344 }
@@ -245,16 +346,21 @@ func runProfileSet(c *Ctx, args []string) int {
245346 return c.fail(protocol.ExitFailure, "%v", err)
246347 }
247348 return emitProfile(c, profileOut{Name: c.User.Username, Kind: "user",
248 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
349 Description: p.Description, Website: p.Website, About: p.About,
350 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
249351}
250352
251353func runOrgProfile(c *Ctx, args []string) int {
252 rest, desc, site, err := parseProfileFlags(args)
253 if err != nil || len(rest) != 1 {
254 return c.fail(protocol.ExitUsage, "usage: org profile <org> [--description <d>] [--website <url>]")
354 rest, e, err := parseProfileFlags(c, args)
355 if err != nil {
356 return c.fail(protocol.ExitUsage, "%v", err)
357 }
358 if len(rest) != 1 {
359 return c.fail(protocol.ExitUsage,
360 "usage: org profile <org> [--description <d>] [--website <url>] [--about <text>|--file -] [--about-format md|org] [--link <label|url>]...")
255361 }
256362 name := rest[0]
257 if desc == nil && site == nil {
363 if e.empty() {
258364 return runProfileShow(c, []string{name})
259365 }
260366 org, code := orgAdmin(c, name)
@@ -265,7 +371,7 @@ func runOrgProfile(c *Ctx, args []string) int {
265371 if err != nil {
266372 return c.fail(protocol.ExitFailure, "%v", err)
267373 }
268 p, err = applyProfile(p, desc, site)
374 p, err = applyProfile(p, e)
269375 if err != nil {
270376 return c.fail(protocol.ExitUsage, "%v", err)
271377 }
@@ -273,5 +379,6 @@ func runOrgProfile(c *Ctx, args []string) int {
273379 return c.fail(protocol.ExitFailure, "%v", err)
274380 }
275381 return emitProfile(c, profileOut{Name: org.Name, Kind: "org",
276 Description: p.Description, Website: p.Website, Repos: []profileRepo{}})
382 Description: p.Description, Website: p.Website, About: p.About,
383 AboutFormat: p.AboutFormat, Links: p.Links, Repos: []profileRepo{}})
277384}
internal/httpd/web.go +17 −1
@@ -398,6 +398,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
398398 Owner string
399399 Kind string
400400 Profile store.Profile
401 AboutHTML template.HTML
401402 Repos []describedRepo
402403 Members []store.OrgMember
403404 Orgs []store.OrgMember
@@ -406,7 +407,8 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
406407 Teams []teamView
407408 CanAdmin bool
408409 Notice string
409 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
410 }{s.baseFor(viewer), name, kind, profile, aboutHTML(profile),
411 s.describeAll(visible), members, orgs,
410412 weeks, activityTotal, teams, canAdmin, r.URL.Query().Get("e")})
411413}
412414
@@ -989,6 +991,20 @@ func mdHTML(raw string) template.HTML {
989991 return template.HTML(buf.String())
990992}
991993
994// aboutHTML renders a profile's about text. It has no filename to
995// dispatch on, so the stored format picks the extension; anything other
996// than org is markdown.
997func aboutHTML(p store.Profile) template.HTML {
998 if strings.TrimSpace(p.About) == "" {
999 return ""
1000 }
1001 name := "about.md"
1002 if p.AboutFormat == "org" {
1003 name = "about.org"
1004 }
1005 return renderReadme(name, []byte(p.About))
1006}
1007
9921008// webResolver answers autolink lookups for one viewer. Cross-repo
9931009// references to repositories the viewer cannot read stay plain text, per
9941010// the enumeration rule: a link would confirm the repo exists.
internal/store/migrations/0026_profile_about.down.sql added +6
@@ -0,0 +1,6 @@
1ALTER TABLE users DROP COLUMN about;
2ALTER TABLE users DROP COLUMN about_format;
3ALTER TABLE users DROP COLUMN links;
4ALTER TABLE orgs DROP COLUMN about;
5ALTER TABLE orgs DROP COLUMN about_format;
6ALTER TABLE orgs DROP COLUMN links;
internal/store/migrations/0026_profile_about.up.sql added +6
@@ -0,0 +1,6 @@
1ALTER TABLE users ADD COLUMN about TEXT NOT NULL DEFAULT '';
2ALTER TABLE users ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
3ALTER TABLE users ADD COLUMN links TEXT NOT NULL DEFAULT '';
4ALTER TABLE orgs ADD COLUMN about TEXT NOT NULL DEFAULT '';
5ALTER TABLE orgs ADD COLUMN about_format TEXT NOT NULL DEFAULT 'md';
6ALTER TABLE orgs ADD COLUMN links TEXT NOT NULL DEFAULT '';
internal/store/orgs.go +37 −5
@@ -2,6 +2,7 @@ package store
22
33import (
44 "database/sql"
5 "encoding/json"
56 "errors"
67 "fmt"
78)
@@ -220,23 +221,54 @@ func (s *Store) RenameOrg(orgID int64, newName string) error {
220221type Profile struct {
221222 Description string `json:"description,omitempty"`
222223 Website string `json:"website,omitempty"`
224 About string `json:"about,omitempty"`
225 // AboutFormat is "md" or "org"; About has no filename to dispatch on.
226 AboutFormat string `json:"about_format,omitempty"`
227 Links []ProfileLink `json:"links,omitempty"`
228}
229
230// ProfileLink is one free-form link. The label is optional; a link
231// without one renders as its URL.
232type ProfileLink struct {
233 Label string `json:"label,omitempty"`
234 URL string `json:"url"`
223235}
224236
225237// OwnerProfile reads the profile for kind "user" or "org".
226238func (s *Store) OwnerProfile(kind string, id int64) (Profile, error) {
227239 table := map[string]string{"user": "users", "org": "orgs"}[kind]
228240 var p Profile
241 var linksJSON string
229242 err := s.DB.QueryRow(
230 "SELECT description, website FROM "+table+" WHERE id = ?", id).
231 Scan(&p.Description, &p.Website)
232 return p, err
243 "SELECT description, website, about, about_format, links FROM "+table+" WHERE id = ?", id).
244 Scan(&p.Description, &p.Website, &p.About, &p.AboutFormat, &linksJSON)
245 if err != nil {
246 return p, err
247 }
248 if linksJSON != "" {
249 if err := json.Unmarshal([]byte(linksJSON), &p.Links); err != nil {
250 return p, fmt.Errorf("%s %d links: %w", kind, id, err)
251 }
252 }
253 return p, nil
233254}
234255
235256// SetOwnerProfile updates the profile for kind "user" or "org".
236257func (s *Store) SetOwnerProfile(kind string, id int64, p Profile) error {
237258 table := map[string]string{"user": "users", "org": "orgs"}[kind]
259 if p.AboutFormat != "org" {
260 p.AboutFormat = "md"
261 }
262 links := ""
263 if len(p.Links) > 0 {
264 raw, err := json.Marshal(p.Links)
265 if err != nil {
266 return err
267 }
268 links = string(raw)
269 }
238270 _, err := s.DB.Exec(
239 "UPDATE "+table+" SET description = ?, website = ? WHERE id = ?",
240 p.Description, p.Website, id)
271 "UPDATE "+table+" SET description = ?, website = ?, about = ?, about_format = ?, links = ? WHERE id = ?",
272 p.Description, p.Website, p.About, p.AboutFormat, links, id)
241273 return err
242274}
internal/web/static/style.css +3 −3
@@ -700,8 +700,8 @@ h2 .count { color: var(--muted); font-weight: 400; font-size: var(--fs-2); }
700700}
701701.headrow .spacer { flex: 1; }
702702
703/* member and org chips on owner pages */
704a.memberchip {
703/* member, org and profile link chips on owner pages */
704a.memberchip, a.linkchip {
705705 display: inline-block;
706706 border: 1px solid var(--line);
707707 border-radius: var(--r-pill);
@@ -710,7 +710,7 @@ a.memberchip {
710710 font-size: var(--fs-1);
711711 margin: 0 0.15rem 0.15rem 0;
712712}
713a.memberchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
713a.memberchip:hover, a.linkchip:hover { text-decoration: none; border-color: var(--accent); color: var(--accent); }
714714a.memberchip .role { color: var(--muted); }
715715
716716/* landing page */
internal/web/templates/owner.html +2
@@ -4,9 +4,11 @@
44<h1>{{.Owner}} <span class="chip chip-neutral">{{.Kind}}</span></h1>
55{{if .Profile.Description}}<p class="desc lede">{{.Profile.Description}}</p>{{end}}
66{{if .Profile.Website}}<p class="meta"><a href="{{.Profile.Website}}" rel="nofollow me">{{.Profile.Website}}</a></p>{{end}}
7{{if .Profile.Links}}<p class="meta">{{range .Profile.Links}}<a class="linkchip" href="{{.URL}}" rel="nofollow me">{{if .Label}}{{.Label}}{{else}}{{.URL}}{{end}}</a> {{end}}</p>{{end}}
78{{if .Orgs}}<p class="meta">member of {{range .Orgs}}<a class="memberchip" href="/{{.Username}}">{{.Username}}</a> {{end}}</p>{{end}}
89{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Username}}">{{.Username}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
910</section>
11{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
1012<section class="activity">
1113<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
1214<div class="actgraph-scroll">