GoAT Site is library that implements Standard.site in Go.
atprotocol standard-site atproto library
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(lexicon): encode into and decode from json

+581 -56
+56 -6
document.go
··· 1 1 package site 2 2 3 - import "time" 3 + import ( 4 + "encoding/json" 5 + "strings" 6 + "time" 7 + ) 4 8 5 9 const LexiconDocument = LexiconBase + ".document" 6 10 ··· 24 28 // Max graphemes: 3000. 25 29 Description *string `json:"description,omitempty"` 26 30 // CoverImage to used for thumbnail or cover. 27 - // Less than 1MB is size. 28 - CoverImage any `json:"-"` 29 - // Content is an open union used to define the [Document]'s content. 30 - // Each entry must specify a `$type`. 31 - Content []any `json:"-"` 31 + // Less than 1MB in size. 32 + CoverImage *Blob `json:"coverImage,omitempty"` 33 + // Content is a custom [Lexicon] used to define the [Document]'s content. 34 + Content *LexiconJSON `json:"content,omitempty"` 32 35 // TextContent is a plaintext representation of the [Document.Content]. 33 36 // Should not contain markdown or other formatting. 34 37 TextContent string `json:"textContent,omitempty"` 35 38 // BlueskyPostRef is a strong reference to a Bluesky post. 36 39 // Useful to keep track of comments off-platform. 40 + // 41 + // Currently, the type is [any], because I don't understand how I should represent it. 42 + // It looks like to be a strong ref, but a record generated by `leaflet.pub`: 43 + // 1. doesn't include a `$type` 44 + // 2. has more info than required by the lexicon used 37 45 BlueskyPostRef any `json:"bskyPostRef,omitempty"` 38 46 // Tags is an array of strings used to tag or categorize the [Document]. 39 47 // Avoid prepending tags with hashtags. ··· 47 55 func (d *Document) Type() string { 48 56 return LexiconDocument 49 57 } 58 + 59 + func (d *Document) MarshalMap() (map[string]any, error) { 60 + type t Document 61 + mp, err := MarshalToMap(t(*d)) 62 + if err != nil { 63 + return nil, err 64 + } 65 + if v, ok := mp["path"]; ok && !strings.HasPrefix(v.(string), "/") { 66 + mp["path"] = "/" + v.(string) 67 + } 68 + mp["publishedAt"] = d.PublishedAt.UTC().Format(TimeFormat) 69 + if d.UpdatedAt != nil { 70 + tn := d.UpdatedAt.UTC().Format(TimeFormat) 71 + mp["updatedAt"] = &tn 72 + } 73 + return mp, nil 74 + } 75 + 76 + func (d *Document) UnmarshalJSON(b []byte) error { 77 + type t Document 78 + var v struct { 79 + t 80 + PublishedAt string `json:"publishedAt"` 81 + UpdatedAt *string `json:"updatedAt,omitempty"` 82 + } 83 + err := json.Unmarshal(b, &v) 84 + if err != nil { 85 + return err 86 + } 87 + v.t.PublishedAt, err = time.Parse(TimeFormat, v.PublishedAt) 88 + if err != nil { 89 + return err 90 + } 91 + if v.UpdatedAt != nil { 92 + v.t.PublishedAt, err = time.Parse(TimeFormat, *v.UpdatedAt) 93 + if err != nil { 94 + return err 95 + } 96 + } 97 + *d = Document(v.t) 98 + return nil 99 + }
+55
document_test.go
··· 1 + package site_test 2 + 3 + import ( 4 + "encoding/json" 5 + "slices" 6 + "testing" 7 + "time" 8 + 9 + site "tangled.org/anhgelus.world/goat-site" 10 + ) 11 + 12 + const sampleDoc = ` 13 + {"$type":"site.standard.document","bskyPostRef":{"cid":"bafyreidepvhssy3zglq3bo4nauszqhqmbk6lzzfay3r2nskvijyiewlr2u","commit":{"cid":"bafyreickwfv4p2jr6zvbdk6mldmddag2m6grpkbbvkvz57mvaqso5dpf5e","rev":"3mhm4oeyyzi2g"},"uri":"at://did:plc:jdhpqeb4cb4mng533dx56cbc/app.bsky.feed.post/3mhm4oevhmk2d","validationStatus":"valid"},"content":{"$type":"pub.leaflet.content","pages":[{"$type":"pub.leaflet.pages.linearDocument","blocks":[{"$type":"pub.leaflet.pages.linearDocument#block","block":{"$type":"pub.leaflet.blocks.text","plaintext":"hiiiiiiiii"}}],"id":"019d1297-2fdd-733b-9837-911e1758f300"}]},"path":"/3mhm4obhnx22y","publishedAt":"2026-03-21T22:52:35.182Z","site":"at://did:plc:jdhpqeb4cb4mng533dx56cbc/site.standard.publication/3mhm4m2tets2y","tags":[],"title":"hello world"} 14 + ` 15 + 16 + func TestDocument_JSON(t *testing.T) { 17 + var v *site.LexiconJSON 18 + err := json.Unmarshal([]byte(sampleDoc), &v) 19 + if err != nil { 20 + t.Fatal(err) 21 + } 22 + doc := v.Lexicon.(*site.Document) 23 + if doc.Site != `at://did:plc:jdhpqeb4cb4mng533dx56cbc/site.standard.publication/3mhm4m2tets2y` { 24 + t.Errorf("invalid site: %s", doc.Site) 25 + } 26 + if doc.Title != `hello world` { 27 + t.Errorf("invalid title: %s", doc.Title) 28 + } 29 + tt, _ := time.Parse(site.TimeFormat, "2026-03-21T22:52:35.182Z") 30 + if !doc.PublishedAt.Equal(tt) { 31 + t.Errorf("invalid publishedAt: %s", doc.PublishedAt.Format(site.TimeFormat)) 32 + } 33 + if *doc.Path != `/3mhm4obhnx22y` { 34 + t.Errorf("invalid path: %s", *doc.Path) 35 + } 36 + 37 + if doc.Content.Lexicon != nil { 38 + t.Errorf("invalid content lexicon: %v", doc.Content.Lexicon) 39 + } 40 + if doc.Content.Type != `pub.leaflet.content` { 41 + t.Errorf("invalid content type: %s", doc.Content.Type) 42 + } 43 + if !slices.Equal(doc.Content.Raw, []byte(`{"$type":"pub.leaflet.content","pages":[{"$type":"pub.leaflet.pages.linearDocument","blocks":[{"$type":"pub.leaflet.pages.linearDocument#block","block":{"$type":"pub.leaflet.blocks.text","plaintext":"hiiiiiiiii"}}],"id":"019d1297-2fdd-733b-9837-911e1758f300"}]}`)) { 44 + t.Errorf("invalid content raw: %s", doc.Content.Raw) 45 + } 46 + if len(doc.Tags) > 0 { 47 + t.Errorf("invalid tags: %v", doc.Tags) 48 + } 49 + 50 + b, err := json.Marshal(v) 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + t.Log(string(b)) 55 + }
+83 -34
lexicons.go
··· 2 2 3 3 import ( 4 4 "encoding/json" 5 - "errors" 6 5 ) 7 6 8 7 type Lexicon interface { 9 8 Type() string 10 9 } 11 10 12 - const LexiconBase = "site.standard" 11 + const ( 12 + LexiconBase = "site.standard" 13 + LexiconBlob = "blob" 14 + 15 + TimeFormat = "2006-01-02T15:04:05.000Z" 16 + ) 13 17 14 - // LexiconJSON is used to convert [Lexicon] into JSON. 18 + // LexiconJSON is used to encode and decode [Lexicon] from JSON. 15 19 type LexiconJSON struct { 16 - Lexicon 20 + // Lexicon parsed. 21 + // Nil if [Lexicon] is unknown. 22 + Lexicon Lexicon 23 + // Type stored if [Lexicon] is unknown. 24 + // Set after [json.Unmarshal]. 25 + Type string 26 + // Raw returns bytes stored if [Lexicon] is unknown. 27 + // Set after [json.Unmarshal]. 28 + Raw []byte 29 + } 30 + 31 + func (l *LexiconJSON) MarshalJSON() ([]byte, error) { 32 + if l.Lexicon == nil { 33 + return l.Raw, nil 34 + } 35 + mp, err := l.MarshalMap() 36 + if err != nil { 37 + return nil, err 38 + } 39 + mp["$type"] = l.Lexicon.Type() 40 + return json.Marshal(mp) 17 41 } 18 42 19 - func (l LexiconJSON) MarshalJSON() ([]byte, error) { 20 - v := struct { 21 - any 22 - Type string `json:"$type"` 23 - }{nil, l.Type()} 24 - switch l.Type() { 25 - case LexiconPublication: 26 - v.any = l.Lexicon.(*Publication) 27 - case LexiconDocument: 28 - v.any = l.Lexicon.(*Document) 29 - case LexiconSubscription: 30 - v.any = l.Lexicon.(*Subscription) 31 - case LexiconTheme: 32 - v.any = l.Lexicon.(*Theme) 33 - case LexiconThemeColorRGB: 34 - v.any = l.Lexicon.(*RGB) 35 - case LexiconThemeColorRGBA: 36 - v.any = l.Lexicon.(*RGBA) 37 - default: 38 - return nil, errors.New("unsupported lexicon type") 43 + func (l *LexiconJSON) MarshalMap() (mp map[string]any, err error) { 44 + if l.Lexicon == nil { 45 + err = json.Unmarshal(l.Raw, &mp) 46 + return 39 47 } 40 - return json.Marshal(v) 48 + mp, err = MarshalToMap(l.Lexicon) 49 + return 41 50 } 42 51 43 - func (l LexiconJSON) UnmarshalJSON(b []byte) error { 52 + func (l *LexiconJSON) UnmarshalJSON(b []byte) error { 44 53 var v struct { 45 54 Type string `json:"$type"` 46 55 } ··· 50 59 } 51 60 switch v.Type { 52 61 case LexiconPublication: 53 - l.Lexicon = l.Lexicon.(*Publication) 62 + l.Lexicon = &Publication{} 54 63 case LexiconDocument: 55 - l.Lexicon = l.Lexicon.(*Document) 64 + l.Lexicon = &Document{} 56 65 case LexiconSubscription: 57 - l.Lexicon = l.Lexicon.(*Subscription) 58 - case LexiconTheme: 59 - l.Lexicon = l.Lexicon.(*Theme) 66 + l.Lexicon = &Subscription{} 67 + case LexiconThemeBasic: 68 + l.Lexicon = &Theme{} 60 69 case LexiconThemeColorRGB: 61 - l.Lexicon = l.Lexicon.(*RGB) 70 + l.Lexicon = &RGB{} 62 71 case LexiconThemeColorRGBA: 63 - l.Lexicon = l.Lexicon.(*RGBA) 72 + l.Lexicon = &RGBA{} 73 + case LexiconBlob: 74 + l.Lexicon = &Blob{} 64 75 default: 65 - return errors.New("unsupported lexicon type") 76 + l.Raw = b 77 + l.Type = v.Type 78 + return nil 66 79 } 67 80 return json.Unmarshal(b, l.Lexicon) 68 81 } 82 + 83 + // Blob represents an ATProto `blob` type. 84 + type Blob struct { 85 + CID string `json:"-"` 86 + MimeType string `json:"mimeType"` 87 + Size uint `json:"size"` 88 + } 89 + 90 + func (b *Blob) Type() string { 91 + return LexiconBlob 92 + } 93 + 94 + func (b *Blob) MarshalMap() (map[string]any, error) { 95 + mp := make(map[string]any, 3) 96 + mp["mimeType"] = b.MimeType 97 + mp["size"] = b.Size 98 + mp["ref"] = map[string]any{"$link": b.CID} 99 + return mp, nil 100 + } 101 + 102 + func (b *Blob) UnmarshalJSON(data []byte) error { 103 + type t Blob 104 + var v struct { 105 + t 106 + Ref struct { 107 + Link string `json:"$link"` 108 + } `json:"ref"` 109 + } 110 + err := json.Unmarshal(data, &v) 111 + if err != nil { 112 + return err 113 + } 114 + *b = Blob(v.t) 115 + b.CID = v.Ref.Link 116 + return nil 117 + }
+120
map.go
··· 1 + package site 2 + 3 + import ( 4 + "fmt" 5 + "reflect" 6 + "slices" 7 + "strings" 8 + ) 9 + 10 + // MarshalerMap implements a custom [MarshalToMap]. 11 + type MarshalerMap interface { 12 + MarshalMap() (map[string]any, error) 13 + } 14 + 15 + type options struct { 16 + key string 17 + fn func(any) (any, bool) 18 + } 19 + 20 + func newOpt(key string, fn func(any) (any, bool)) options { 21 + return options{key, fn} 22 + } 23 + 24 + var opts = []options{ 25 + newOpt("omitempty", func(v any) (any, bool) { 26 + if v == nil { 27 + return nil, false 28 + } 29 + refVal := reflect.ValueOf(v) 30 + if reflect.DeepEqual(v, reflect.Zero(refVal.Type()).Interface()) { 31 + return v, false 32 + } 33 + return v, true 34 + }), 35 + newOpt("string", func(v any) (any, bool) { return fmt.Sprintf("%v", v), true }), 36 + } 37 + 38 + func getElem(v reflect.Value) (any, error) { 39 + if !v.CanInterface() { 40 + return nil, nil 41 + } 42 + val := v.Interface() 43 + if conv, ok := val.(Lexicon); ok { 44 + val = &LexiconJSON{Lexicon: conv} 45 + } 46 + if conv, ok := val.(MarshalerMap); ok { 47 + return conv.MarshalMap() 48 + } 49 + switch v.Kind() { 50 + case reflect.Struct: 51 + return MarshalToMap(val) 52 + case reflect.Pointer: 53 + if v.IsNil() { 54 + return nil, nil 55 + } 56 + return getElem(v.Elem()) 57 + default: 58 + return val, nil 59 + } 60 + } 61 + 62 + // MarshalToMap transforms a struct into a map. 63 + // 64 + // If v is not a map, it returns nil. 65 + // 66 + // Implements [MarshalerMap] to have a custom behavior. 67 + func MarshalToMap(v any) (map[string]any, error) { 68 + ref := reflect.ValueOf(v) 69 + switch ref.Kind() { 70 + case reflect.Struct: 71 + case reflect.Pointer: 72 + if ref.IsNil() { 73 + return nil, nil 74 + } 75 + return MarshalToMap(ref.Elem().Interface()) 76 + default: 77 + return nil, nil 78 + } 79 + if conv, ok := v.(MarshalerMap); ok { 80 + return conv.MarshalMap() 81 + } 82 + refType := ref.Type() 83 + fields := ref.NumField() 84 + mp := make(map[string]any, fields) 85 + for i := range fields { 86 + field := ref.Field(i) 87 + fieldType := refType.Field(i) 88 + val, err := getElem(field) 89 + if err != nil { 90 + return nil, err 91 + } 92 + name := fieldType.Name 93 + data := strings.Split(refType.Field(i).Tag.Get("json"), ",") 94 + if len(data) > 0 { 95 + if len(data[0]) > 0 { 96 + name = data[0] 97 + } 98 + if name == "-" { 99 + continue 100 + } 101 + if len(data) > 1 { 102 + tagOpts := data[1:] 103 + ok := true 104 + i := 0 105 + for i < len(opts) && ok { 106 + opt := opts[i] 107 + if slices.Contains(tagOpts, opt.key) { 108 + val, ok = opt.fn(val) 109 + } 110 + i++ 111 + } 112 + if !ok { 113 + continue 114 + } 115 + } 116 + } 117 + mp[name] = val 118 + } 119 + return mp, nil 120 + }
+97
map_test.go
··· 1 + package site_test 2 + 3 + import ( 4 + "fmt" 5 + "reflect" 6 + "testing" 7 + 8 + site "tangled.org/anhgelus.world/goat-site" 9 + ) 10 + 11 + func validMap(t *testing.T, check, res map[string]any) { 12 + if len(check) != len(res) { 13 + t.Errorf("invalid value: got %v, wanted %v", check, res) 14 + } 15 + for k, val := range check { 16 + if !reflect.DeepEqual(res[k], val) { 17 + t.Errorf("invalid value at %s, got %v, wanted %v", k, val, res[k]) 18 + } 19 + } 20 + } 21 + 22 + type test1 struct { 23 + A string `json:"a"` 24 + B string `json:",omitempty"` 25 + } 26 + 27 + func TestMarshalToMap_Simple(t *testing.T) { 28 + h := test1{"aaa", ""} 29 + v, err := site.MarshalToMap(h) 30 + if err != nil { 31 + t.Fatal(err) 32 + } 33 + validMap(t, v, map[string]any{"a": "aaa"}) 34 + 35 + h = test1{"", "bbb"} 36 + v, err = site.MarshalToMap(h) 37 + if err != nil { 38 + t.Fatal(err) 39 + } 40 + validMap(t, v, map[string]any{"a": "", "B": "bbb"}) 41 + } 42 + 43 + type test2 struct { 44 + Hey *test1 `json:"hey,omitempty"` 45 + Key string `json:"key"` 46 + } 47 + 48 + func TestMarshalToMap_Nested(t *testing.T) { 49 + h := test2{&test1{"aaa", ""}, "key"} 50 + v, err := site.MarshalToMap(h) 51 + if err != nil { 52 + t.Fatal(err) 53 + } 54 + validMap(t, v, map[string]any{"hey": map[string]any{"a": "aaa"}, "key": "key"}) 55 + 56 + h = test2{nil, "k"} 57 + v, err = site.MarshalToMap(h) 58 + if err != nil { 59 + t.Fatal(err) 60 + } 61 + validMap(t, v, map[string]any{"key": "k"}) 62 + } 63 + 64 + func (t test1) String() string { 65 + return fmt.Sprintf("%s:%s", t.A, t.B) 66 + } 67 + 68 + type test3 struct { 69 + Default int `json:"default"` 70 + ConvDef int `json:"conv,string"` 71 + } 72 + 73 + func TestMarshalToMap_String(t *testing.T) { 74 + h := test3{1, 1} 75 + v, err := site.MarshalToMap(h) 76 + if err != nil { 77 + t.Fatal(err) 78 + } 79 + validMap(t, v, map[string]any{"default": 1, "conv": "1"}) 80 + } 81 + 82 + type test4 struct { 83 + A int 84 + } 85 + 86 + func (t test4) MarshalMap() (map[string]any, error) { 87 + return map[string]any{"a": t.A + 5}, nil 88 + } 89 + 90 + func TestMarshalToMap_CustomMarshal(t *testing.T) { 91 + h := test4{0} 92 + v, err := site.MarshalToMap(h) 93 + if err != nil { 94 + t.Fatal(err) 95 + } 96 + validMap(t, v, map[string]any{"a": 5}) 97 + }
+16 -8
publication.go
··· 1 1 package site 2 2 3 + import "strings" 4 + 3 5 const LexiconPublication = LexiconBase + ".publication" 4 6 5 7 // Publication represents a collection of documents published to the web. ··· 11 13 // Base URL of the [Publication]. 12 14 // This value will be combined with the [Document.Path] to construct a full URL for the document. 13 15 // Avoid trailing slashes. 14 - URL string `json:"string"` 16 + URL string `json:"url"` 15 17 // Name of the [Publication]. 16 18 // Max length: 5000. 17 19 // Max graphemes: 500. 18 20 Name string `json:"name"` 19 21 // Icon to identify the [Publication]. 20 22 // Must be a square image and should be at least 256x256. 21 - Icon any `json:"-"` 23 + Icon *Blob `json:"icon,omitempty"` 22 24 // Description of the [Publication]. 23 25 // Max length: 30000. 24 26 // Max graphemes: 3000. 25 27 Description *string `json:"description,omitempty"` 26 28 // Simplified theme for tools and apps to utilize when displaying content. 27 - // Ref to `site.standard.theme.basic`. 28 - BasicTheme any `json:"basicTheme,omitempty"` 29 + BasicTheme *Theme `json:"basicTheme,omitempty"` 29 30 // Platform-specific [Preferences] for the [Publication], including discovery and visibility settings. 30 31 Preferences *Preferences `json:"preferences,omitempty"` 31 32 } 32 33 34 + func (p *Publication) Type() string { 35 + return LexiconPublication 36 + } 37 + 38 + func (p *Publication) MarshalMap() (map[string]any, error) { 39 + type t Publication 40 + pp := t(*p) 41 + pp.URL = strings.TrimSuffix(pp.URL, "/") 42 + return MarshalToMap(pp) 43 + } 44 + 33 45 // Preferences of the [Publication]. 34 46 type Preferences struct { 35 47 // ShowInDiscover decides whether the [Publication] should appear in discovery feeds. 36 48 ShowInDiscover bool `json:"showInDiscover"` 37 49 } 38 - 39 - func (p *Publication) Type() string { 40 - return LexiconPublication 41 - }
+121
publication_test.go
··· 1 + package site_test 2 + 3 + import ( 4 + "encoding/json" 5 + "testing" 6 + 7 + site "tangled.org/anhgelus.world/goat-site" 8 + ) 9 + 10 + const samplePub = `{ 11 + "$type": "site.standard.publication", 12 + "basicTheme": { 13 + "$type": "site.standard.theme.basic", 14 + "accent": { 15 + "$type": "site.standard.theme.color#rgb", 16 + "b": 20, 17 + "g": 105, 18 + "r": 139 19 + }, 20 + "accentForeground": { 21 + "$type": "site.standard.theme.color#rgb", 22 + "b": 204, 23 + "g": 243, 24 + "r": 255 25 + }, 26 + "background": { 27 + "$type": "site.standard.theme.color#rgb", 28 + "b": 225, 29 + "g": 249, 30 + "r": 255 31 + }, 32 + "foreground": { 33 + "$type": "site.standard.theme.color#rgb", 34 + "b": 32, 35 + "g": 53, 36 + "r": 74 37 + } 38 + }, 39 + "description": "the latest and greatest from pckt !", 40 + "icon": { 41 + "$type": "blob", 42 + "ref": { 43 + "$link": "bafkreia3gaejwdadslicpqbgtzitcysop7lhuyry6bjf6xlf5fe7jvvcdy" 44 + }, 45 + "mimeType": "image/png", 46 + "size": 8535 47 + }, 48 + "name": "pckt - Dev Journal", 49 + "preferences": { 50 + "showInDiscover": true 51 + }, 52 + "theme": { 53 + "$type": "blog.pckt.theme", 54 + "dark": { 55 + "accent": "#ffc947", 56 + "background": "#3d2a1a", 57 + "link": "#ffe082", 58 + "surfaceHover": "#4d3822", 59 + "text": "#fff9e0" 60 + }, 61 + "font": "sans", 62 + "light": { 63 + "accent": "#8b6914", 64 + "background": "#fff9e1", 65 + "link": "#d68910", 66 + "surfaceHover": "#fff3cc", 67 + "text": "#4a3520" 68 + }, 69 + "transparency": 100 70 + }, 71 + "url": "https://devlog.pckt.blog" 72 + }` 73 + 74 + func TestPublication_JSON(t *testing.T) { 75 + var v *site.LexiconJSON 76 + err := json.Unmarshal([]byte(samplePub), &v) 77 + if err != nil { 78 + t.Fatal(err) 79 + } 80 + pub := v.Lexicon.(*site.Publication) 81 + if pub.Name != "pckt - Dev Journal" { 82 + t.Errorf("invalid name: %s", pub.Name) 83 + } 84 + if pub.URL != "https://devlog.pckt.blog" { 85 + t.Errorf("invalid url: %s", pub.URL) 86 + } 87 + if *pub.Description != "the latest and greatest from pckt !" { 88 + t.Errorf("invalid description: %s", *pub.Description) 89 + } 90 + if pub.Icon.CID != "bafkreia3gaejwdadslicpqbgtzitcysop7lhuyry6bjf6xlf5fe7jvvcdy" { 91 + t.Errorf("invalid Icon CID: %s", pub.Icon.CID) 92 + } 93 + if pub.Icon.MimeType != "image/png" { 94 + t.Errorf("invalid Icon MimeType: %s", pub.Icon.MimeType) 95 + } 96 + if pub.Icon.Size != 8535 { 97 + t.Errorf("invalid Icon Size: %d", pub.Icon.Size) 98 + } 99 + if !pub.Preferences.ShowInDiscover { 100 + t.Errorf("invalid Preferences ShowInDiscover: %v", pub.Preferences.ShowInDiscover) 101 + } 102 + theme := pub.BasicTheme 103 + if *theme.Accent != *site.NewRGB(139, 105, 20) { 104 + t.Errorf("invalid theme accent color: %s", theme.Accent) 105 + } 106 + if *theme.AccentForeground != *site.NewRGB(255, 243, 204) { 107 + t.Errorf("invalid theme accent foreground color: %s", theme.AccentForeground) 108 + } 109 + if *theme.Background != *site.NewRGB(255, 249, 225) { 110 + t.Errorf("invalid theme background color: %s", theme.Background) 111 + } 112 + if *theme.Foreground != *site.NewRGB(74, 53, 32) { 113 + t.Errorf("invalid theme foreground color: %s", theme.Foreground) 114 + } 115 + 116 + b, err := json.Marshal(v) 117 + if err != nil { 118 + t.Fatal(err) 119 + } 120 + t.Log(string(b)) 121 + }
+33 -8
theme.go
··· 1 1 package site 2 2 3 3 import ( 4 + "fmt" 4 5 "image/color" 5 6 "math" 6 7 ) 7 8 8 9 const ( 9 10 LexiconTheme = LexiconBase + ".theme" 11 + LexiconThemeBasic = LexiconTheme + ".basic" 10 12 LexiconThemeColor = LexiconTheme + ".color" 11 13 LexiconThemeColorRGB = LexiconThemeColor + "#rgb" 12 14 LexiconThemeColorRGBA = LexiconThemeColor + "#rgba" ··· 16 18 // defining core colors for content display. 17 19 type Theme struct { 18 20 // Background is the color used for content background. 19 - Background any `json:"background"` 21 + Background *RGB `json:"background"` 20 22 // Foreground is the color used for content text. 21 - Foreground any `json:"foreground"` 23 + Foreground *RGB `json:"foreground"` 22 24 // Accent is the color used for links and button backgrounds. 23 - Accent any `json:"accent"` 25 + Accent *RGB `json:"accent"` 24 26 // AccentForeground is the color used for button text. 25 - AccentForeground any `json:"accentForeground"` 27 + AccentForeground *RGB `json:"accentForeground"` 26 28 } 27 29 28 30 func (t *Theme) Type() string { 29 - return LexiconTheme 31 + return LexiconThemeBasic 30 32 } 31 33 32 34 // RGB represents a RGB color. 35 + // 36 + // See also [RGBA]. 33 37 type RGB struct { 34 38 Red uint8 `json:"r"` 35 39 Green uint8 `json:"g"` 36 40 Blue uint8 `json:"b"` 37 41 } 38 42 43 + func NewRGB(r, g, b uint8) *RGB { 44 + return &RGB{r, g, b} 45 + } 46 + 39 47 func (r *RGB) Type() string { 40 48 return LexiconThemeColorRGB 41 49 } ··· 44 52 return &RGBA{*r, 100} 45 53 } 46 54 55 + func (r *RGB) String() string { 56 + return fmt.Sprintf("RGB(%d %d %d)", r.Red, r.Green, r.Blue) 57 + } 58 + 47 59 // RGBA represents a [color.RGBA]. 60 + // 61 + // See also [RGB]. 48 62 type RGBA struct { 49 63 RGB 50 64 // Alpha is the alpha channel where 0 is transparent and 100 is opaque. 51 65 Alpha uint8 `json:"a"` 52 66 } 53 67 54 - func (r *RGBA) Type() string { 55 - return LexiconThemeColorRGBA 68 + func NewRawRGBA(r, g, b, a uint8) *RGBA { 69 + if a > 100 { 70 + panic("invalid alpha: must be <= 100") 71 + } 72 + return &RGBA{*NewRGB(r, g, b), a} 56 73 } 57 74 58 75 func NewRGBA(r *color.RGBA) *RGBA { 59 76 red, g, b, a := r.RGBA() 60 77 return &RGBA{ 61 - RGB: RGB{uint8(red), uint8(g), uint8(b)}, 78 + RGB: *NewRGB(uint8(red), uint8(g), uint8(b)), 62 79 Alpha: uint8(math.Floor(float64(a)/float64(255)) * 100), 63 80 } 64 81 } 82 + 83 + func (r *RGBA) Type() string { 84 + return LexiconThemeColorRGBA 85 + } 86 + 87 + func (r *RGBA) String() string { 88 + return fmt.Sprintf("RGBA(%d %d %d %d)", r.Red, r.Green, r.Blue, r.Alpha) 89 + }