nandi/gleanpublic⑂ Fork 0
⑂ 72289c4
Commits
⬇ Clone ▾
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Refactor atproto client to use APIClient directly and fix UIUnverified

Julien Robert committed 2026-04-20T17:49:04+02:00 Browse files
72289c4 parent: a6093b7
modified .gitignore +1 -1
@@ -23,7 +23,7 @@ go.work.sum
2323
2424 # database
2525 *.db
26-*.db.journal
26+*.db-journal
2727
2828 # tailwind
2929 static/output.css
@@ -23,7 +23,7 @@ go.work.sum
23 23
24 # database24 # database
25 *.db25 *.db
26-*.db.journal26+*.db-journal
27 27
28 # tailwind28 # tailwind
29 static/output.css29 static/output.css
modified internal/atproto/client.go +8 -145
@@ -1,82 +1,23 @@
11 package atproto
22
33 import (
4- "bytes"
54 "context"
65 "encoding/json"
76 "fmt"
8- "net/http"
97
108 "github.com/bluesky-social/indigo/atproto/atclient"
119 "github.com/bluesky-social/indigo/atproto/syntax"
1210 )
1311
1412 type Client struct {
15- httpClient *http.Client
16- pdsURL string
17- accessToken string
18- APIClient *atclient.APIClient
13+ api *atclient.APIClient
1914 }
2015
21-func NewClient(pdsURL, accessToken string) *Client {
22- return &Client{
23- httpClient: &http.Client{},
24- pdsURL: pdsURL,
25- accessToken: accessToken,
26- }
16+func NewClient(api *atclient.APIClient) *Client {
17+ return &Client{api: api}
2718 }
2819
2920 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {
30- if c.APIClient != nil {
31- return c.createRecordWithAPI(ctx, did, collection, record)
32- }
33-
34- nsid, err := syntax.ParseNSID(collection)
35- if err != nil {
36- return "", "", fmt.Errorf("parsing collection NSID: %w", err)
37- }
38-
39- body := map[string]any{
40- "repo": did,
41- "collection": nsid.String(),
42- "record": record,
43- }
44-
45- data, err := json.Marshal(body)
46- if err != nil {
47- return "", "", err
48- }
49-
50- url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord"
51- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
52- if err != nil {
53- return "", "", err
54- }
55- req.Header.Set("Content-Type", "application/json")
56- req.Header.Set("Authorization", "Bearer "+c.accessToken)
57-
58- resp, err := c.httpClient.Do(req)
59- if err != nil {
60- return "", "", err
61- }
62- defer resp.Body.Close()
63-
64- if resp.StatusCode != http.StatusOK {
65- return "", "", fmt.Errorf("create record returned %d", resp.StatusCode)
66- }
67-
68- var result struct {
69- URI string `json:"uri"`
70- CID string `json:"cid"`
71- }
72- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
73- return "", "", err
74- }
75-
76- return result.URI, result.CID, nil
77-}
78-
79-func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string, record any) (string, string, error) {
8021 input := map[string]any{
8122 "repo": did,
8223 "collection": collection,
@@ -93,7 +34,7 @@ func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string
9334 return "", "", fmt.Errorf("parsing NSID: %w", err)
9435 }
9536
96- if err := c.APIClient.Post(ctx, nsid, input, &out); err != nil {
37+ if err := c.api.Post(ctx, nsid, input, &out); err != nil {
9738 return "", "", err
9839 }
9940 return out.URI, out.CID, nil
@@ -106,93 +47,15 @@ func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string)
10647 "rkey": rkey,
10748 }
10849
109- if c.APIClient != nil {
110- nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord")
111- if err != nil {
112- return fmt.Errorf("parsing NSID: %w", err)
113- }
114- return c.APIClient.Post(ctx, nsid, input, nil)
115- }
116-
117- data, err := json.Marshal(input)
118- if err != nil {
119- return err
120- }
121-
122- url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord"
123- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
124- if err != nil {
125- return err
126- }
127- req.Header.Set("Content-Type", "application/json")
128- req.Header.Set("Authorization", "Bearer "+c.accessToken)
129-
130- resp, err := c.httpClient.Do(req)
50+ nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord")
13151 if err != nil {
132- return err
52+ return fmt.Errorf("parsing NSID: %w", err)
13353 }
134- defer resp.Body.Close()
13554
136- if resp.StatusCode != http.StatusOK {
137- return fmt.Errorf("delete record returned %d", resp.StatusCode)
138- }
139-
140- return nil
55+ return c.api.Post(ctx, nsid, input, nil)
14156 }
14257
14358 func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
144- if c.APIClient != nil {
145- return c.listRecordsWithAPI(ctx, did, collection, limit, cursor)
146- }
147-
148- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection)
149- if limit > 0 {
150- url += fmt.Sprintf("&limit=%d", limit)
151- }
152- if cursor != "" {
153- url += "&cursor=" + cursor
154- }
155-
156- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
157- if err != nil {
158- return nil, "", err
159- }
160- req.Header.Set("Authorization", "Bearer "+c.accessToken)
161-
162- resp, err := c.httpClient.Do(req)
163- if err != nil {
164- return nil, "", err
165- }
166- defer resp.Body.Close()
167-
168- if resp.StatusCode != http.StatusOK {
169- return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode)
170- }
171-
172- var result struct {
173- Records []struct {
174- URI string `json:"uri"`
175- CID string `json:"cid"`
176- Value json.RawMessage `json:"value"`
177- } `json:"records"`
178- Cursor string `json:"cursor"`
179- }
180- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
181- return nil, "", err
182- }
183-
184- records := make([]Record, len(result.Records))
185- for i, r := range result.Records {
186- records[i] = Record{
187- URI: r.URI,
188- CID: r.CID,
189- Value: r.Value,
190- }
191- }
192- return records, result.Cursor, nil
193-}
194-
195-func (c *Client) listRecordsWithAPI(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
19659 nsid, err := syntax.ParseNSID("com.atproto.repo.listRecords")
19760 if err != nil {
19861 return nil, "", fmt.Errorf("parsing NSID: %w", err)
@@ -218,7 +81,7 @@ func (c *Client) listRecordsWithAPI(ctx context.Context, did, collection string,
21881 Cursor string `json:"cursor"`
21982 }
22083
221- if err := c.APIClient.Get(ctx, nsid, params, &result); err != nil {
84+ if err := c.api.Get(ctx, nsid, params, &result); err != nil {
22285 return nil, "", err
22386 }
22487
@@ -1,82 +1,23 @@
1 package atproto1 package atproto
2 2
3 import (3 import (
4- "bytes"
5 "context"4 "context"
6 "encoding/json"5 "encoding/json"
7 "fmt"6 "fmt"
8- "net/http"
9 7
10 "github.com/bluesky-social/indigo/atproto/atclient"8 "github.com/bluesky-social/indigo/atproto/atclient"
11 "github.com/bluesky-social/indigo/atproto/syntax"9 "github.com/bluesky-social/indigo/atproto/syntax"
12 )10 )
13 11
14 type Client struct {12 type Client struct {
15- httpClient *http.Client13+ api *atclient.APIClient
16- pdsURL string
17- accessToken string
18- APIClient *atclient.APIClient
19 }14 }
20 15
21-func NewClient(pdsURL, accessToken string) *Client {16+func NewClient(api *atclient.APIClient) *Client {
22- return &Client{17+ return &Client{api: api}
23- httpClient: &http.Client{},
24- pdsURL: pdsURL,
25- accessToken: accessToken,
26- }
27 }18 }
28 19
29 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {20 func (c *Client) CreateRecord(ctx context.Context, did, collection string, record any) (string, string, error) {
30- if c.APIClient != nil {
31- return c.createRecordWithAPI(ctx, did, collection, record)
32- }
33-
34- nsid, err := syntax.ParseNSID(collection)
35- if err != nil {
36- return "", "", fmt.Errorf("parsing collection NSID: %w", err)
37- }
38-
39- body := map[string]any{
40- "repo": did,
41- "collection": nsid.String(),
42- "record": record,
43- }
44-
45- data, err := json.Marshal(body)
46- if err != nil {
47- return "", "", err
48- }
49-
50- url := c.pdsURL + "/xrpc/com.atproto.repo.createRecord"
51- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
52- if err != nil {
53- return "", "", err
54- }
55- req.Header.Set("Content-Type", "application/json")
56- req.Header.Set("Authorization", "Bearer "+c.accessToken)
57-
58- resp, err := c.httpClient.Do(req)
59- if err != nil {
60- return "", "", err
61- }
62- defer resp.Body.Close()
63-
64- if resp.StatusCode != http.StatusOK {
65- return "", "", fmt.Errorf("create record returned %d", resp.StatusCode)
66- }
67-
68- var result struct {
69- URI string `json:"uri"`
70- CID string `json:"cid"`
71- }
72- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
73- return "", "", err
74- }
75-
76- return result.URI, result.CID, nil
77-}
78-
79-func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string, record any) (string, string, error) {
80 input := map[string]any{21 input := map[string]any{
81 "repo": did,22 "repo": did,
82 "collection": collection,23 "collection": collection,
@@ -93,7 +34,7 @@ func (c *Client) createRecordWithAPI(ctx context.Context, did, collection string
93 return "", "", fmt.Errorf("parsing NSID: %w", err)34 return "", "", fmt.Errorf("parsing NSID: %w", err)
94 }35 }
95 36
96- if err := c.APIClient.Post(ctx, nsid, input, &out); err != nil {37+ if err := c.api.Post(ctx, nsid, input, &out); err != nil {
97 return "", "", err38 return "", "", err
98 }39 }
99 return out.URI, out.CID, nil40 return out.URI, out.CID, nil
@@ -106,93 +47,15 @@ func (c *Client) DeleteRecord(ctx context.Context, did, collection, rkey string)
106 "rkey": rkey,47 "rkey": rkey,
107 }48 }
108 49
109- if c.APIClient != nil {50+ nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord")
110- nsid, err := syntax.ParseNSID("com.atproto.repo.deleteRecord")
111- if err != nil {
112- return fmt.Errorf("parsing NSID: %w", err)
113- }
114- return c.APIClient.Post(ctx, nsid, input, nil)
115- }
116-
117- data, err := json.Marshal(input)
118- if err != nil {
119- return err
120- }
121-
122- url := c.pdsURL + "/xrpc/com.atproto.repo.deleteRecord"
123- req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(data))
124- if err != nil {
125- return err
126- }
127- req.Header.Set("Content-Type", "application/json")
128- req.Header.Set("Authorization", "Bearer "+c.accessToken)
129-
130- resp, err := c.httpClient.Do(req)
131 if err != nil {51 if err != nil {
132- return err52+ return fmt.Errorf("parsing NSID: %w", err)
133 }53 }
134- defer resp.Body.Close()
135 54
136- if resp.StatusCode != http.StatusOK {55+ return c.api.Post(ctx, nsid, input, nil)
137- return fmt.Errorf("delete record returned %d", resp.StatusCode)
138- }
139-
140- return nil
141 }56 }
142 57
143 func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {58 func (c *Client) ListRecords(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
144- if c.APIClient != nil {
145- return c.listRecordsWithAPI(ctx, did, collection, limit, cursor)
146- }
147-
148- url := fmt.Sprintf("%s/xrpc/com.atproto.repo.listRecords?repo=%s&collection=%s", c.pdsURL, did, collection)
149- if limit > 0 {
150- url += fmt.Sprintf("&limit=%d", limit)
151- }
152- if cursor != "" {
153- url += "&cursor=" + cursor
154- }
155-
156- req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
157- if err != nil {
158- return nil, "", err
159- }
160- req.Header.Set("Authorization", "Bearer "+c.accessToken)
161-
162- resp, err := c.httpClient.Do(req)
163- if err != nil {
164- return nil, "", err
165- }
166- defer resp.Body.Close()
167-
168- if resp.StatusCode != http.StatusOK {
169- return nil, "", fmt.Errorf("list records returned %d", resp.StatusCode)
170- }
171-
172- var result struct {
173- Records []struct {
174- URI string `json:"uri"`
175- CID string `json:"cid"`
176- Value json.RawMessage `json:"value"`
177- } `json:"records"`
178- Cursor string `json:"cursor"`
179- }
180- if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
181- return nil, "", err
182- }
183-
184- records := make([]Record, len(result.Records))
185- for i, r := range result.Records {
186- records[i] = Record{
187- URI: r.URI,
188- CID: r.CID,
189- Value: r.Value,
190- }
191- }
192- return records, result.Cursor, nil
193-}
194-
195-func (c *Client) listRecordsWithAPI(ctx context.Context, did, collection string, limit int, cursor string) ([]Record, string, error) {
196 nsid, err := syntax.ParseNSID("com.atproto.repo.listRecords")59 nsid, err := syntax.ParseNSID("com.atproto.repo.listRecords")
197 if err != nil {60 if err != nil {
198 return nil, "", fmt.Errorf("parsing NSID: %w", err)61 return nil, "", fmt.Errorf("parsing NSID: %w", err)
@@ -218,7 +81,7 @@ func (c *Client) listRecordsWithAPI(ctx context.Context, did, collection string,
218 Cursor string `json:"cursor"`81 Cursor string `json:"cursor"`
219 }82 }
220 83
221- if err := c.APIClient.Get(ctx, nsid, params, &result); err != nil {84+ if err := c.api.Get(ctx, nsid, params, &result); err != nil {
222 return nil, "", err85 return nil, "", err
223 }86 }
224 87
modified internal/server/auth_handler.go +1 -1
@@ -157,7 +157,7 @@ func (s *Server) pdsClientFromSession(sessData *oauth.ClientSessionData) *atprot
157157 s.logger.Warn("failed to resume session for sync", "error", err)
158158 return nil
159159 }
160- return &atproto.Client{APIClient: session.APIClient()}
160+ return atproto.NewClient(session.APIClient())
161161 }
162162
163163 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
@@ -157,7 +157,7 @@ func (s *Server) pdsClientFromSession(sessData *oauth.ClientSessionData) *atprot
157 s.logger.Warn("failed to resume session for sync", "error", err)157 s.logger.Warn("failed to resume session for sync", "error", err)
158 return nil158 return nil
159 }159 }
160- return &atproto.Client{APIClient: session.APIClient()}160+ return atproto.NewClient(session.APIClient())
161 }161 }
162 162
163 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {163 func (s *Server) handleOAuthClientMetadata(w http.ResponseWriter, r *http.Request) {
modified internal/server/server.go +12 -16
@@ -229,24 +229,20 @@ func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
229229 return nil
230230 }
231231
232- if session.SessionID != "" {
233- did, err := syntax.ParseDID(session.DID)
234- if err != nil {
235- return nil
236- }
237- sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID)
238- if err != nil {
239- s.logger.Warn("failed to resume OAuth session", "error", err)
240- return nil
241- }
242- apiClient := sess.APIClient()
243- return &atproto.Client{APIClient: apiClient}
232+ if session.SessionID == "" {
233+ return nil
244234 }
245235
246- if session.AccessToken != "" && session.PDSURL != "" {
247- return atproto.NewClient(session.PDSURL, session.AccessToken)
236+ did, err := syntax.ParseDID(session.DID)
237+ if err != nil {
238+ return nil
239+ }
240+ sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID)
241+ if err != nil {
242+ s.logger.Warn("failed to resume OAuth session", "error", err)
243+ return nil
248244 }
249- return nil
245+ return atproto.NewClient(sess.APIClient())
250246 }
251247
252248 func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) {
@@ -298,7 +294,7 @@ func (s *Server) runSyncAll(ctx context.Context) {
298294 continue
299295 }
300296
301- client := &atproto.Client{APIClient: sess.APIClient()}
297+ client := atproto.NewClient(sess.APIClient())
302298 sync := atproto.NewSync(s.db, client, s.logger)
303299 if err := sync.Run(ctx, u.DID); err != nil {
304300 s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
@@ -229,24 +229,20 @@ func (s *Server) pdsClientForUser(r *http.Request) *atproto.Client {
229 return nil229 return nil
230 }230 }
231 231
232- if session.SessionID != "" {232+ if session.SessionID == "" {
233- did, err := syntax.ParseDID(session.DID)233+ return nil
234- if err != nil {
235- return nil
236- }
237- sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID)
238- if err != nil {
239- s.logger.Warn("failed to resume OAuth session", "error", err)
240- return nil
241- }
242- apiClient := sess.APIClient()
243- return &atproto.Client{APIClient: apiClient}
244 }234 }
245 235
246- if session.AccessToken != "" && session.PDSURL != "" {236+ did, err := syntax.ParseDID(session.DID)
247- return atproto.NewClient(session.PDSURL, session.AccessToken)237+ if err != nil {
238+ return nil
239+ }
240+ sess, err := s.oauth.ResumeSession(r.Context(), did, session.SessionID)
241+ if err != nil {
242+ s.logger.Warn("failed to resume OAuth session", "error", err)
243+ return nil
248 }244 }
249- return nil245+ return atproto.NewClient(sess.APIClient())
250 }246 }
251 247
252 func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) {248 func (s *Server) syncUserInBackground(userDID string, client *atproto.Client) {
@@ -298,7 +294,7 @@ func (s *Server) runSyncAll(ctx context.Context) {
298 continue294 continue
299 }295 }
300 296
301- client := &atproto.Client{APIClient: sess.APIClient()}297+ client := atproto.NewClient(sess.APIClient())
302 sync := atproto.NewSync(s.db, client, s.logger)298 sync := atproto.NewSync(s.db, client, s.logger)
303 if err := sync.Run(ctx, u.DID); err != nil {299 if err := sync.Run(ctx, u.DID); err != nil {
304 s.logger.Error("periodic sync failed", "error", err, "did", u.DID)300 s.logger.Error("periodic sync failed", "error", err, "did", u.DID)
modified internal/tmpl/feeds.html +1 -1
@@ -123,7 +123,7 @@
123123 <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
124124 <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
125125 </form>
126- <a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>
126+ {{if .Subscriptions}}<a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>{{end}}
127127 </div>
128128 </div>
129129
@@ -123,7 +123,7 @@
123 <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">123 <input type="file" name="opml" accept=".opml,.xml" class="text-sm text-spot-secondary file:mr-2 file:py-1 file:px-3 file:rounded-pill file:border-0 file:text-sm file:bg-spot-hover file:text-spot-text hover:file:bg-spot-surface file:cursor-pointer">
124 <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>124 <button type="submit" class="w-full border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Import OPML</button>
125 </form>125 </form>
126- <a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>126+ {{if .Subscriptions}}<a href="/feeds/opml/download" class="block w-full text-center border border-spot-outline text-spot-text rounded-pill px-4 py-1.5 text-sm font-bold uppercase tracking-button hover:border-spot-text transition">Export OPML</a>{{end}}
127 </div>127 </div>
128 </div>128 </div>
129 129
modified internal/tmpl/partials/logo.html +2 -2
@@ -15,7 +15,7 @@
1515
1616 {{define "logo-link"}}<a href="/" class="text-spot-green font-bold text-xl tracking-tight flex items-center gap-2">
1717 <span class="w-7 h-7">{{template "logo-icon"}}</span>
18- Glean.at
18+ <span class="font-bold text-xl text-spot-text">Glean</span>
1919 </a>{{end}}
2020
21-{{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight">Glean.at</a>{{end}}
21+{{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight"><span class="font-bold text-xl text-spot-text">Glean</span>.at</a>{{end}}
@@ -15,7 +15,7 @@
15 15
16 {{define "logo-link"}}<a href="/" class="text-spot-green font-bold text-xl tracking-tight flex items-center gap-2">16 {{define "logo-link"}}<a href="/" class="text-spot-green font-bold text-xl tracking-tight flex items-center gap-2">
17 <span class="w-7 h-7">{{template "logo-icon"}}</span>17 <span class="w-7 h-7">{{template "logo-icon"}}</span>
18- Glean.at18+ <span class="font-bold text-xl text-spot-text">Glean</span>
19 </a>{{end}}19 </a>{{end}}
20 20
21-{{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight">Glean.at</a>{{end}}21+{{define "logo-text"}}<a href="/" class="text-spot-green font-bold text-lg tracking-tight"><span class="font-bold text-xl text-spot-text">Glean</span>.at</a>{{end}}