Kind Realm (renderable)
Name home
Namespace g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c
Exported functions 11
Module gno.land/r/g1747t5m2f08plqjlrjk2q0qld7465hxz8gkx59c/home
gno 0.9 gallery.gno gno
⧉
1 package home 2 3 import ( 4 "encoding/base64" 5 "strings" 6 7 "gno.land/p/nt/avl/v0" 8 Append (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, slug string, body string)
Delete (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, slug string)
DeleteGallery (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string)
Get (slug string) string
HasHeart (addr string) bool
Heart (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string})
Hearts () int
Render (path string) string
Revision () int
Set (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, slug string, body string)
SetGallery (cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, name string, spec string)
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
)
9
10 // This file is shared, unchanged, by every Samouraï home realm.
11 //
12 // A gallery is a named list of cards. gnoweb's CSP only paints images from a
13 // few hosts (YouTube thumbnails are not among them) but it allows
14 // data:image/svg+xml, so every card is a poster drawn here, on chain, and
15 // linked to its video or site.
16 //
17 // A gallery is written in one Set-like call as lines of
18 //
19 // kind | title | meta | url [| description]
20 //
21 // The optional description becomes the card's hover text.
22 // and rendered wherever the layout says :gallery.<name>:.
23
24 const (
25 galleryPrefix = "gallery."
26 cardsPerRow = 3
27 maxCards = 48
28 maxTitleLen = 80
29 maxMetaLen = 80
30 maxDescLen = 200
31 )
32
33 type card struct {
34 kind, title, meta, url string
35 desc string // hover text, optional
36 label string // overrides the kind's label when set
37 }
38
39 var galleries = avl.NewTree() // name -> []card
40
41 type style struct {
42 label, glyph, accent, shade string
43 video bool
44 }
45
46 var styles = map [ string ]style{
47 "report" : { "CONTRIBUTION REPORT" , "報" , "#e63946" , "#3a1014" , true },
48 "gno" : { "GNO.LAND · FILMED BY SAMOURAÏ" , "道" , "#2ec4b6" , "#0d2e2b" , true },
49 "webtrip" : { "WEB TRIP · SERIES" , "旅" , "#ff7aa2" , "#3a1422" , true },
50 "peerdev" : { "PEER DEV · GNO TUTORIAL" , "学" , "#4ea8de" , "#0f2536" , true },
51 "event" : { "EVENT" , "祭" , "#f4a261" , "#3a2412" , true },
52 "festival" : { "FESTIVAL · BY SAMOURAÏ" , "祭" , "#f4a261" , "#3a2412" , false },
53 "film" : { "FILM · DIRECTED BY THE CREW" , "映" , "#b388eb" , "#261a36" , true },
54 "product" : { "PRODUCT" , "作" , "#e9c46a" , "#352b10" , false },
55 "memba" : { "MEMBA · PRODUCT" , "組" , "#e9c46a" , "#352b10" , false },
56 "game" : { "ARCADE · PLAY IN MEMBA" , "遊" , "#8ac926" , "#1c2a0b" , true },
57 "oss" : { "OPEN SOURCE · GITHUB" , "源" , "#a8b3bd" , "#1b2026" , false },
58 "member" : { "SAMOURAÏ · CREW" , "侍" , "#e63946" , "#2a1012" , false },
59 "member-media" : { "SAMOURAÏ · MEDIA" , "映" , "#b388eb" , "#261a36" , false },
60 "member-alumni" : { "SAMOURAÏ · ALUMNI" , "侍" , "#9aa0a6" , "#1d1f22" , false },
61 }
62
63 func styleOf(kind string ) style {
64 if s, ok := styles[kind]; ok {
65 return s
66 }
67 return style{strings.ToUpper(kind), "侍" , "#e63946" , "#2a1012" , false }
68 }
69
70 // SetGallery replaces a whole gallery from its line format.
71 func SetGallery(cur realm, name, spec string ) {
72 assertAdmin(cur)
73 if !validSlug(name) {
74 panic( "invalid gallery name: " + name)
75 }
76 cards := parseGallery(spec)
77 rev++
78 galleries.Set(name, cards)
79 }
80
81 // DeleteGallery removes a gallery.
82 func DeleteGallery(cur realm, name string ) {
83 assertAdmin(cur)
84 if _, removed := galleries.Remove(name); !removed {
85 panic( "no such gallery: " + name)
86 }
87 rev++
88 }
89
90 func parseGallery(spec string ) []card {
91 var cards []card
92 for _, line := range strings.Split(spec, "\n" ) {
93 line = strings.TrimSpace(line)
94 if line == "" || strings.HasPrefix(line, "#" ) {
95 continue
96 }
97 f := strings.Split(line, "|" )
98 if len(f) != 4 && len(f) != 5 {
99 panic( "gallery line needs 4 or 5 fields (kind | title | meta | url [| description]): " + line)
100 }
101 c := card{
102 kind: strings.TrimSpace(f[ 0 ]),
103 title: strings.TrimSpace(f[ 1 ]),
104 meta: strings.TrimSpace(f[ 2 ]),
105 url: strings.TrimSpace(f[ 3 ]),
106 }
107 if len(f) == 5 {
108 c.desc = strings.TrimSpace(f[ 4 ])
109 if len(c.desc) > maxDescLen || strings.ContainsAny(c.desc, "\"\\" ) {
110 panic( "gallery description must be at most 200 bytes, without quotes or backslashes: " + line)
111 }
112 }
113 if !validSlug(c.kind) || c.title == "" || len(c.title) > maxTitleLen || len(c.meta) > maxMetaLen {
114 panic( "bad gallery line: " + line)
115 }
116 if !strings.HasPrefix(c.url, "https://" ) && !strings.HasPrefix(c.url, "/" ) {
117 panic( "gallery url must be https:// or a /path: " + c.url)
118 }
119 if strings.ContainsAny(c.url, " ()<>\"" ) {
120 panic( "gallery url has characters markdown would break on: " + c.url)
121 }
122 cards = append(cards, c)
123 }
124 if len(cards) == 0 || len(cards) > maxCards {
125 panic( "a gallery holds 1 to 48 cards" )
126 }
127 return cards
128 }
129
130 // renderGallery lays cards out cardsPerRow to a row, each one a poster that
131 // links to its target.
132 func renderGallery(name string ) ( string , bool ) {
133 v := galleries.Get(name)
134 if v == nil {
135 return "" , false
136 }
137 cards := v.([]card)
138 var b strings.Builder
139 for i := 0 ; i < len(cards); i += cardsPerRow {
140 b.WriteString( "<gno-columns>\n" )
141 for j := i; j < i+cardsPerRow; j++ {
142 if j > i {
143 b.WriteString( "<gno-columns-sep />\n" )
144 }
145 if j < len(cards) {
146 c := cards[j]
147 b.WriteString( "[ + ")](" + c.url + linkTitle(c.desc) + ")\n" )
148 }
149 }
150 b.WriteString( "</gno-columns>\n\n" )
151 }
152 return strings.TrimSuffix(b.String(), "\n\n" ), true
153 }
154
155 // linkTitle renders an optional markdown link title, which gnoweb emits as
156 // the link's hover text.
157 func linkTitle(desc string ) string {
158 if desc == "" {
159 return ""
160 }
161 return ` "` + desc + `"`
162 }
163
164 // mdText keeps a title from closing the markdown image alt text early.
165 func mdText(s string ) string {
166 return strings.NewReplacer( "[" , "(" , "]" , ")" ).Replace(s)
167 }
168
169 func xmlText(s string ) string {
170 return strings.NewReplacer( "&" , "&" , "<" , "<" , ">" , ">" , "\"" , """ , "'" , "'" ).Replace(s)
171 }
172
173 // wrap splits s into at most two lines of about width runes; the second line
174 // is cut with an ellipsis when the title is longer.
175 func wrap(s string , width int ) [] string {
176 words := strings.Fields(s)
177 var lines [] string
178 cur := ""
179 for _, w := range words {
180 if cur == "" {
181 cur = w
182 } else if runeLen(cur)+ 1 +runeLen(w) <= width {
183 cur += " " + w
184 } else {
185 lines = append(lines, cur)
186 cur = w
187 }
188 }
189 if cur != "" {
190 lines = append(lines, cur)
191 }
192 if len(lines) > 2 {
193 second := [] rune (strings.Join(lines[ 1 :], " " ))
194 if len(second) > width {
195 second = append(second[:width- 1 ], '…' )
196 }
197 lines = [] string {lines[ 0 ], string (second)}
198 }
199 return lines
200 }
201
202 func runeLen(s string ) int { return len([] rune (s)) }
203
204 // clip cuts s to at most n runes, ending with an ellipsis when it had to cut.
205 func clip(s string , n int ) string {
206 r := [] rune (s)
207 if len(r) <= n {
208 return s
209 }
210 return string (append(r[:n- 1 ], '…' ))
211 }
212
213 func posterURI(c card) string {
214 return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([] byte (poster(c)))
215 }
216
217 // poster draws a 320x180 card: a dark gradient, a rising sun in the kind's
218 // accent (a play button for videos), a large kanji watermark, the kind label,
219 // the title on up to two lines and a meta line.
220 func poster(c card) string {
221 st := styleOf(c.kind)
222 if c.label != "" {
223 st.label = c.label
224 }
225 var b strings.Builder
226 b.WriteString( `<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">` )
227 b.WriteString( `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#121212"/><stop offset="1" stop-color="` + st.shade + `"/></linearGradient></defs>` )
228 b.WriteString( `<rect width="320" height="180" rx="14" fill="url(#g)"/>` )
229 b.WriteString( `<text x="306" y="170" text-anchor="end" font-family="serif" font-size="120" fill="#ffffff" fill-opacity=".06">` + st.glyph + `</text>` )
230 b.WriteString( `<circle cx="268" cy="52" r="30" fill="` + st.accent + `"/>` )
231 if st.video {
232 b.WriteString( `<path d="M260 38 L282 52 L260 66 Z" fill="#121212"/>` )
233 } else {
234 b.WriteString( `<path d="M256 52 H280 M272 44 L280 52 L272 60" stroke="#121212" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>` )
235 }
236 b.WriteString( `<rect x="18" y="20" width="3" height="12" fill="` + st.accent + `"/>` )
237 b.WriteString( `<text x="27" y="31" font-family="ui-monospace,Menlo,monospace" font-size="10" letter-spacing="1.5" fill="` + st.accent + `">` + xmlText(st.label) + `</text>` )
238 lines := wrap(c.title, 24 )
239 y := 108
240 if len(lines) == 1 {
241 y = 126
242 }
243 for _, l := range lines {
244 b.WriteString( `<text x="18" y="` + itoa(y) + `" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-weight="700" font-size="20" fill="#ffffff">` + xmlText(l) + `</text>` )
245 y += 25
246 }
247 // The meta line hugs the bottom-right corner: gnoweb draws its
248 // external-link badge over the bottom-left one.
249 b.WriteString( `<text x="304" y="164" text-anchor="end" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bdbdbd">` + xmlText(clip(c.meta, 40 )) + `</text>` )
250 b.WriteString( `</svg>` )
251 return b.String()
252 }
253
254 func itoa(n int ) string {
255 if n == 0 {
256 return "0"
257 }
258 var d [] byte
259 for n > 0 {
260 d = append([] byte { byte ( '0' + n% 10 )}, d...)
261 n /= 10
262 }
263 return string (d)
264 }
265
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.