A CLI-first git forge.

cli forge git self-hosted

https://gitbay.org

Commit 4744c629ed

4744c629ed4f0158bdea7d0f1d207fdccb1f7f99

parent: 68ae423bf6

Verified · cmc ci/build: success

cmc <hello@cleberg.net> · 2026-08-28T18:21:59Z

profile: about text and free-form links

profile set gains --about (inline or --file -) with --about-format md|org,
and --link, repeatable up to five as "label|url" or a bare url. Both
fields live on users and orgs, read and written through store.Profile;
links are a JSON column.

About has no filename to dispatch on, so the stored format picks the
renderer. The web renders it between the profile header and the activity
graph, and links as chips under the website line.
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
49122 func 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
228325 func 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
251353 func 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
33 import (
44 "database/sql"
5 "encoding/json"
56 "errors"
67 "fmt"
78 )
@@ -220,23 +221,54 @@ func (s *Store) RenameOrg(orgID int64, newName string) error {
220221 type 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".
226238 func (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".
236257 func (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); }
714714 a.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">