Skip to content

Commit

Permalink
template: speedup lexer (#1335)
Browse files Browse the repository at this point in the history
* template: add parse benchmarks

Taken from the yagpdb-cc repository. To avoid licensing issues, I only used
programs written by me.

Baseline performance:

name                 time/op
Parse/lorem_ipsum-4  2.01µs ± 7%
Parse/short-4        70.2µs ± 1%
Parse/medium-4        165µs ± 1%
Parse/long-4          517µs ± 1%
Parse/very-long-4    1.79ms ± 1%

name                 alloc/op
Parse/lorem_ipsum-4  1.23kB ± 0%
Parse/short-4        5.48kB ± 0%
Parse/medium-4       12.7kB ± 0%
Parse/long-4         35.3kB ± 0%
Parse/very-long-4     113kB ± 0%

name                 allocs/op
Parse/lorem_ipsum-4    12.0 ± 0%
Parse/short-4           109 ± 0%
Parse/medium-4          298 ± 0%
Parse/long-4            779 ± 0%
Parse/very-long-4     2.61k ± 0%

* template: port upstream changes

Notably, this includes https://go-review.googlesource.com/c/go/+/421883,
which changes the lexer to not spawn a new goroutine.

name                 old time/op    new time/op    delta
Parse/lorem_ipsum-4    2.01µs ± 7%    0.73µs ± 2%  -63.56%  (p=0.000 n=7+8)
Parse/short-4          70.2µs ± 1%    16.6µs ± 1%  -76.32%  (p=0.000 n=7+8)
Parse/medium-4          165µs ± 1%      40µs ± 1%  -75.74%  (p=0.000 n=8+8)
Parse/long-4            517µs ± 1%     123µs ± 1%  -76.28%  (p=0.000 n=8+7)
Parse/very-long-4      1.79ms ± 1%    0.42ms ± 1%  -76.81%  (p=0.000 n=8+8)

name                 old alloc/op   new alloc/op   delta
Parse/lorem_ipsum-4    1.23kB ± 0%    1.16kB ± 0%   -5.56%  (p=0.000 n=8+8)
Parse/short-4          5.48kB ± 0%    5.42kB ± 0%   -1.17%  (p=0.000 n=8+8)
Parse/medium-4         12.7kB ± 0%    12.6kB ± 0%   -0.51%  (p=0.000 n=8+8)
Parse/long-4           35.3kB ± 0%    35.3kB ± 0%   -0.17%  (p=0.000 n=8+8)
Parse/very-long-4       113kB ± 0%     113kB ± 0%   -0.05%  (p=0.000 n=8+8)

name                 old allocs/op  new allocs/op  delta
Parse/lorem_ipsum-4      12.0 ± 0%      10.0 ± 0%  -16.67%  (p=0.000 n=8+8)
Parse/short-4             109 ± 0%       107 ± 0%   -1.83%  (p=0.000 n=8+8)
Parse/medium-4            298 ± 0%       296 ± 0%   -0.67%  (p=0.000 n=8+8)
Parse/long-4              779 ± 0%       777 ± 0%   -0.26%  (p=0.000 n=8+8)
Parse/very-long-4       2.61k ± 0%     2.61k ± 0%   -0.08%  (p=0.000 n=8+8)

* template: add ASCII fast path for lexer.next

lexer.next is a hot function, as demonstrated by profiling. Most programs
will consist of ASCII characters only, which we can optimize for. Ideally
DecodeRuneInString would be inlined here and this wouldn't be a problem at all,
but that won't be the case until golang/go#31666
is resolved.

name                 old time/op    new time/op    delta
Parse/lorem_ipsum-4     733ns ± 2%     733ns ± 2%    ~     (p=0.933 n=8+8)
Parse/short-4          16.6µs ± 1%    15.5µs ± 2%  -6.75%  (p=0.000 n=8+8)
Parse/medium-4         40.1µs ± 1%    38.7µs ± 1%  -3.51%  (p=0.000 n=8+8)
Parse/long-4            123µs ± 1%     115µs ± 1%  -5.86%  (p=0.001 n=7+7)
Parse/very-long-4       416µs ± 1%     396µs ± 1%  -4.70%  (p=0.000 n=8+8)

name                 old alloc/op   new alloc/op   delta
Parse/lorem_ipsum-4    1.16kB ± 0%    1.16kB ± 0%    ~     (all equal)
Parse/short-4          5.42kB ± 0%    5.42kB ± 0%    ~     (all equal)
Parse/medium-4         12.6kB ± 0%    12.6kB ± 0%    ~     (all equal)
Parse/long-4           35.3kB ± 0%    35.3kB ± 0%    ~     (all equal)
Parse/very-long-4       113kB ± 0%     113kB ± 0%    ~     (all equal)

name                 old allocs/op  new allocs/op  delta
Parse/lorem_ipsum-4      10.0 ± 0%      10.0 ± 0%    ~     (all equal)
Parse/short-4             107 ± 0%       107 ± 0%    ~     (all equal)
Parse/medium-4            296 ± 0%       296 ± 0%    ~     (all equal)
Parse/long-4              777 ± 0%       777 ± 0%    ~     (all equal)
Parse/very-long-4       2.61k ± 0%     2.61k ± 0%    ~     (all equal)

* template: preallocate for 4 args in CommandNode

CommandNode.append and by extension runtime.growslice was showing up
more than expected during profiling. Allocate enough space for four
arguments up front so we don't need to reallocate as much. Although
this doesn't benefit performance much, it does have a clear positive effect on
memory usage.

name                 old time/op    new time/op    delta
Parse/lorem_ipsum-4     733ns ± 2%     728ns ± 2%     ~     (p=0.315 n=8+8)
Parse/short-4          15.5µs ± 2%    15.2µs ± 1%   -2.12%  (p=0.001 n=8+8)
Parse/medium-4         38.7µs ± 1%    38.0µs ± 1%   -1.99%  (p=0.000 n=8+8)
Parse/long-4            115µs ± 1%     117µs ± 5%     ~     (p=0.281 n=7+8)
Parse/very-long-4       396µs ± 1%     400µs ± 1%   +1.02%  (p=0.002 n=8+8)

name                 old alloc/op   new alloc/op   delta
Parse/lorem_ipsum-4    1.16kB ± 0%    1.16kB ± 0%     ~     (all equal)
Parse/short-4          5.42kB ± 0%    5.08kB ± 0%   -6.20%  (p=0.000 n=8+8)
Parse/medium-4         12.6kB ± 0%    12.7kB ± 0%   +0.51%  (p=0.000 n=8+8)
Parse/long-4           35.3kB ± 0%    34.4kB ± 0%   -2.58%  (p=0.000 n=8+8)
Parse/very-long-4       113kB ± 0%     110kB ± 0%   -2.37%  (p=0.000 n=8+8)

name                 old allocs/op  new allocs/op  delta
Parse/lorem_ipsum-4      10.0 ± 0%      10.0 ± 0%     ~     (all equal)
Parse/short-4             107 ± 0%        93 ± 0%  -13.08%  (p=0.000 n=8+8)
Parse/medium-4            296 ± 0%       276 ± 0%   -6.76%  (p=0.000 n=8+8)
Parse/long-4              777 ± 0%       705 ± 0%   -9.27%  (p=0.000 n=8+8)
Parse/very-long-4       2.61k ± 0%     2.35k ± 0%   -9.77%  (p=0.000 n=8+8)
  • Loading branch information
jo3-l committed Aug 29, 2022
1 parent 6a44f37 commit 5ec0a82
Show file tree
Hide file tree
Showing 10 changed files with 685 additions and 150 deletions.
253 changes: 127 additions & 126 deletions lib/template/parse/lex.go

Large diffs are not rendered by default.

17 changes: 0 additions & 17 deletions lib/template/parse/lex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,7 +372,6 @@ var lexTests = []lexTest{
{"extra right paren", "{{3)}}", []item{
tLeft,
mkItem(itemNumber, "3"),
tRpar,
mkItem(itemError, `unexpected right paren U+0029 ')'`),
}},

Expand Down Expand Up @@ -543,22 +542,6 @@ func TestPos(t *testing.T) {
}
}

// Test that an error shuts down the lexing goroutine.
func TestShutdown(t *testing.T) {
// We need to duplicate template.Parse here to hold on to the lexer.
const text = "erroneous{{define}}{{else}}1234"
lexer := lex("foo", text, "{{", "}}")
_, err := New("root").parseLexer(lexer)
if err == nil {
t.Fatalf("expected error")
}
// The error should have drained the input. Therefore, the lexer should be shut down.
token, ok := <-lexer.items
if ok {
t.Fatalf("input was not drained; got %v", token)
}
}

// parseLexer is a local version of parse that lets us pass in the lexer instead of building it.
// We expect an error, so the tree set and funcs list are explicitly nil.
func (t *Tree) parseLexer(lex *lexer) (tree *Tree, err error) {
Expand Down
2 changes: 1 addition & 1 deletion lib/template/parse/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,7 +247,7 @@ type CommandNode struct {
}

func (t *Tree) newCommand(pos Pos) *CommandNode {
return &CommandNode{tr: t, NodeType: NodeCommand, Pos: pos}
return &CommandNode{tr: t, NodeType: NodeCommand, Pos: pos, Args: make([]Node, 0, 4)}
}

func (c *CommandNode) append(arg Node) {
Expand Down
1 change: 0 additions & 1 deletion lib/template/parse/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ func (t *Tree) recover(errp *error) {
panic(e)
}
if t != nil {
t.lex.drain()
t.stopParse()
}
*errp = e.(error)
Expand Down
211 changes: 206 additions & 5 deletions lib/template/parse/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ package parse
import (
"flag"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
Expand Down Expand Up @@ -461,7 +463,7 @@ var errorTests = []parseTest{
hasError, `unclosed left paren`},
{"rparen",
"{{.X 1 2 3)}}",
hasError, `unexpected ")"`},
hasError, `unexpected right paren`},
{"space",
"{{`x`3}}",
hasError, `in operand`},
Expand Down Expand Up @@ -589,12 +591,211 @@ func TestLineNum(t *testing.T) {
}
}

func BenchmarkParseLarge(b *testing.B) {
text := strings.Repeat("{{1234}}\n", 10000)
for i := 0; i < b.N; i++ {
_, err := New("bench").Parse(text, "", "", make(map[string]*Tree), builtins)
func BenchmarkParse(b *testing.B) {
benchmarks := []struct {
name, file string
}{
{"lorem ipsum", "lorem.tmpl"},
{"short", "short.tmpl"},
{"medium", "medium.tmpl"},
{"long", "long.tmpl"},
{"very-long", "very_long.tmpl"},
}
for _, bm := range benchmarks {
f, err := os.ReadFile(filepath.Join("..", "testdata", bm.file))
if err != nil {
b.Fatal(err)
}

in := string(f)
b.Run(bm.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_, err := Parse(bm.name, in, "{{", "}}", funcs)
if err != nil {
b.Fatal(err)
}
}
})
}
}

var funcs = map[string]interface{}{
"add": func() interface{} { return nil },
"addMessageReactions": func() interface{} { return nil },
"addReactions": func() interface{} { return nil },
"addResponseReactions": func() interface{} { return nil },
"addRoleID": func() interface{} { return nil },
"addRoleName": func() interface{} { return nil },
"adjective": func() interface{} { return nil },
"and": func() interface{} { return nil },
"bitwiseAnd": func() interface{} { return nil },
"bitwiseAndNot": func() interface{} { return nil },
"bitwiseLeftShift": func() interface{} { return nil },
"bitwiseNot": func() interface{} { return nil },
"bitwiseOr": func() interface{} { return nil },
"bitwiseRightShift": func() interface{} { return nil },
"bitwiseXor": func() interface{} { return nil },
"call": func() interface{} { return nil },
"cancelScheduledUniqueCC": func() interface{} { return nil },
"carg": func() interface{} { return nil },
"cbrt": func() interface{} { return nil },
"cembed": func() interface{} { return nil },
"complexMessage": func() interface{} { return nil },
"complexMessageEdit": func() interface{} { return nil },
"createTicket": func() interface{} { return nil },
"cslice": func() interface{} { return nil },
"currentTime": func() interface{} { return nil },
"currentUserAgeHuman": func() interface{} { return nil },
"currentUserAgeMinutes": func() interface{} { return nil },
"currentUserCreated": func() interface{} { return nil },
"dbBottomEntries": func() interface{} { return nil },
"dbCount": func() interface{} { return nil },
"dbDel": func() interface{} { return nil },
"dbDelByID": func() interface{} { return nil },
"dbDelById": func() interface{} { return nil },
"dbDelMultiple": func() interface{} { return nil },
"dbGet": func() interface{} { return nil },
"dbGetPattern": func() interface{} { return nil },
"dbGetPatternReverse": func() interface{} { return nil },
"dbIncr": func() interface{} { return nil },
"dbRank": func() interface{} { return nil },
"dbSet": func() interface{} { return nil },
"dbSetExpire": func() interface{} { return nil },
"dbTopEntries": func() interface{} { return nil },
"deleteAllMessageReactions": func() interface{} { return nil },
"deleteMessage": func() interface{} { return nil },
"deleteMessageReaction": func() interface{} { return nil },
"deleteResponse": func() interface{} { return nil },
"deleteTrigger": func() interface{} { return nil },
"dict": func() interface{} { return nil },
"div": func() interface{} { return nil },
"editChannelName": func() interface{} { return nil },
"editChannelTopic": func() interface{} { return nil },
"editMessage": func() interface{} { return nil },
"editMessageNoEscape": func() interface{} { return nil },
"editNickname": func() interface{} { return nil },
"eq": func() interface{} { return nil },
"exec": func() interface{} { return nil },
"execAdmin": func() interface{} { return nil },
"execCC": func() interface{} { return nil },
"execTemplate": func() interface{} { return nil },
"fdiv": func() interface{} { return nil },
"formatTime": func() interface{} { return nil },
"ge": func() interface{} { return nil },
"getChannel": func() interface{} { return nil },
"getChannelOrThread": func() interface{} { return nil },
"getMember": func() interface{} { return nil },
"getMessage": func() interface{} { return nil },
"getPinCount": func() interface{} { return nil },
"getRole": func() interface{} { return nil },
"getTargetPermissionsIn": func() interface{} { return nil },
"getThread": func() interface{} { return nil },
"giveRoleID": func() interface{} { return nil },
"giveRoleName": func() interface{} { return nil },
"gt": func() interface{} { return nil },
"hasPermissions": func() interface{} { return nil },
"hasPrefix": func() interface{} { return nil },
"hasRoleID": func() interface{} { return nil },
"hasRoleName": func() interface{} { return nil },
"hasSuffix": func() interface{} { return nil },
"html": func() interface{} { return nil },
"humanizeDurationHours": func() interface{} { return nil },
"humanizeDurationMinutes": func() interface{} { return nil },
"humanizeDurationSeconds": func() interface{} { return nil },
"humanizeThousands": func() interface{} { return nil },
"humanizeTimeSinceDays": func() interface{} { return nil },
"in": func() interface{} { return nil },
"inFold": func() interface{} { return nil },
"index": func() interface{} { return nil },
"joinStr": func() interface{} { return nil },
"js": func() interface{} { return nil },
"json": func() interface{} { return nil },
"kindOf": func() interface{} { return nil },
"le": func() interface{} { return nil },
"len": func() interface{} { return nil },
"loadLocation": func() interface{} { return nil },
"log": func() interface{} { return nil },
"lower": func() interface{} { return nil },
"lt": func() interface{} { return nil },
"mathConst": func() interface{} { return nil },
"max": func() interface{} { return nil },
"mentionEveryone": func() interface{} { return nil },
"mentionHere": func() interface{} { return nil },
"mentionRoleID": func() interface{} { return nil },
"mentionRoleName": func() interface{} { return nil },
"min": func() interface{} { return nil },
"mod": func() interface{} { return nil },
"mult": func() interface{} { return nil },
"ne": func() interface{} { return nil },
"newDate": func() interface{} { return nil },
"not": func() interface{} { return nil },
"noun": func() interface{} { return nil },
"onlineCount": func() interface{} { return nil },
"onlineCountBots": func() interface{} { return nil },
"or": func() interface{} { return nil },
"parseArgs": func() interface{} { return nil },
"pastNicknames": func() interface{} { return nil },
"pastUsernames": func() interface{} { return nil },
"pinMessage": func() interface{} { return nil },
"pow": func() interface{} { return nil },
"print": func() interface{} { return nil },
"printf": func() interface{} { return nil },
"println": func() interface{} { return nil },
"randInt": func() interface{} { return nil },
"reFind": func() interface{} { return nil },
"reFindAll": func() interface{} { return nil },
"reFindAllSubmatches": func() interface{} { return nil },
"reQuoteMeta": func() interface{} { return nil },
"reReplace": func() interface{} { return nil },
"reSplit": func() interface{} { return nil },
"removeRoleID": func() interface{} { return nil },
"removeRoleName": func() interface{} { return nil },
"roleAbove": func() interface{} { return nil },
"round": func() interface{} { return nil },
"roundCeil": func() interface{} { return nil },
"roundEven": func() interface{} { return nil },
"roundFloor": func() interface{} { return nil },
"scheduleUniqueCC": func() interface{} { return nil },
"sdict": func() interface{} { return nil },
"sendDM": func() interface{} { return nil },
"sendMessage": func() interface{} { return nil },
"sendMessageNoEscape": func() interface{} { return nil },
"sendMessageNoEscapeRetID": func() interface{} { return nil },
"sendMessageRetID": func() interface{} { return nil },
"sendTemplate": func() interface{} { return nil },
"sendTemplateDM": func() interface{} { return nil },
"seq": func() interface{} { return nil },
"setRoles": func() interface{} { return nil },
"shuffle": func() interface{} { return nil },
"sleep": func() interface{} { return nil },
"slice": func() interface{} { return nil },
"snowflakeToTime": func() interface{} { return nil },
"sort": func() interface{} { return nil },
"split": func() interface{} { return nil },
"sqrt": func() interface{} { return nil },
"str": func() interface{} { return nil },
"structToSdict": func() interface{} { return nil },
"sub": func() interface{} { return nil },
"takeRoleID": func() interface{} { return nil },
"takeRoleName": func() interface{} { return nil },
"targetHasPermissions": func() interface{} { return nil },
"targetHasRoleID": func() interface{} { return nil },
"targetHasRoleName": func() interface{} { return nil },
"title": func() interface{} { return nil },
"toByte": func() interface{} { return nil },
"toDuration": func() interface{} { return nil },
"toFloat": func() interface{} { return nil },
"toInt": func() interface{} { return nil },
"toInt64": func() interface{} { return nil },
"toRune": func() interface{} { return nil },
"toString": func() interface{} { return nil },
"trimSpace": func() interface{} { return nil },
"unpinMessage": func() interface{} { return nil },
"upper": func() interface{} { return nil },
"urlescape": func() interface{} { return nil },
"urlquery": func() interface{} { return nil },
"urlunescape": func() interface{} { return nil },
"userArg": func() interface{} { return nil },
"verb": func() interface{} { return nil },
"weekNumber": func() interface{} { return nil },
}
66 changes: 66 additions & 0 deletions lib/template/testdata/long.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
{{/*
Views information about the server.
See <https://yagpdb-cc.github.io/info/server> for more information.

Author: jo3-l <https://github.com/jo3-l>
*/}}

{{ $icon := "" }}
{{ $name := printf "%s (%d)" .Guild.Name .Guild.ID }}
{{ if .Guild.Icon }}
{{ $ext := "webp" }}
{{ if eq (slice .Guild.Icon 0 2) "a_" }} {{ $ext = "gif" }} {{ end }}
{{ $icon = printf "https://cdn.discordapp.com/icons/%d/%s.%s" .Guild.ID .Guild.Icon $ext }}
{{ end }}

{{ $owner := userArg .Guild.OwnerID }}
{{ $levels := cslice
"None: Unrestricted"
"Low: Must have a verified email on their Discord account."
"Medium: Must also be registered on Discord for longer than 5 minutes."
"(╯°□°)╯︵ ┻━┻: Must also be a member of this server for longer than 10 minutes."
"┻━┻ ミヽ(ಠ益ಠ)ノ彡┻━┻: Must have a verified phone on their Discord account."
}}
{{ $afk := "n/a" }}
{{ if .Guild.AfkChannelID }}
{{ $afk = printf "**Channel:** <#%d> (%d)\n**Timeout:** %s"
.Guild.AfkChannelID
.Guild.AfkChannelID
(humanizeDurationSeconds (toDuration (mult .Guild.AfkTimeout .TimeSecond)))
}}
{{ end }}
{{ $embedsEnabled := "No" }}
{{ if .Guild.WidgetEnabled }} {{ $embedsEnabled = "Yes" }} {{ end }}
{{ $createdAt := div .Guild.ID 4194304 | add 1420070400000 | mult 1000000 | toDuration | (newDate 1970 1 1 0 0 0).Add }}

{{ $infoEmbed := cembed
"author" (sdict "name" $name "icon_url" $icon)
"color" 14232643
"thumbnail" (sdict "url" $icon)
"fields" (cslice
(sdict "name" "Verification Level" "value" (index $levels .Guild.VerificationLevel))
(sdict "name" "Region" "value" .Guild.Region)
(sdict "name" "Members" "value" (printf "**• Total:** %d Members\n**• Online:** %d Members" .Guild.MemberCount onlineCount))
(sdict "name" "Roles" "value" (printf "**• Total:** %d\nUse `-listroles` to list all roles." (len .Guild.Roles)))
(sdict "name" "Owner" "value" (printf "%s (%d)" $owner.String $owner.ID))
(sdict "name" "AFK" "value" $afk)
(sdict "name" "Embeds Enabled" "value" $embedsEnabled)
)
"footer" (sdict "text" "Created at")
"timestamp" $createdAt
}}

{{ if .CmdArgs }}
{{ if eq (index .CmdArgs 0) "icon" }}
{{ sendMessage nil (cembed
"author" (sdict "name" $name "icon_url" $icon)
"title" "Server Icon"
"color" 14232643
"image" (sdict "url" $icon)
) }}
{{ else }}
{{ sendMessage nil $infoEmbed }}
{{ end }}
{{ else }}
{{ sendMessage nil $infoEmbed }}
{{ end }}
3 changes: 3 additions & 0 deletions lib/template/testdata/lorem.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
30 changes: 30 additions & 0 deletions lib/template/testdata/medium.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{{/*
Separates predefined flags from positional arguments within input.
See <https://yagpdb-cc.github.io/code-snippets/parse-flags> for more information.

Licensed under the terms of the Unlicense.
Author: jo3-l <https://github.com/jo3-l>
*/}}

{{define "parseFlags"}}
{{.Set "Out" (sdict
"Positional" (cslice)
"Flags" (dict)
"Switches" (dict)
)}}

{{$curFlag := ""}}
{{$lastIdx := sub (len .Args) 1}}
{{range $i, $arg := .Args}}
{{- if $curFlag}}
{{- $.Out.Flags.Set $curFlag $arg}}
{{- $curFlag = ""}}
{{- else if and ($id := $.Flags.Get $arg) (ne $i $lastIdx)}}
{{- $curFlag = $id}}
{{- else if $id := $.Switches.Get $arg}}
{{- $.Out.Switches.Set $id true}}
{{- else}}
{{- $.Out.Set "Positional" ($.Out.Positional.Append $arg)}}
{{- end -}}
{{end}}
{{end}}
15 changes: 15 additions & 0 deletions lib/template/testdata/short.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{{/*
Displays a random color.
See <https://yagpdb-cc.github.io/utilities/random-color> for more information.

Author: jo3-l <https://github.com/jo3-l>
*/}}

{{ $dec := randInt 0 16777216 }}
{{ $hex := printf "%06x" $dec }}
{{ sendMessage nil (cembed
"title" "Random Color"
"color" $dec
"description" (printf "**Decimal:** %d\n**Hex:** #%s" $dec $hex)
"thumbnail" (sdict "url" (printf "https://dummyimage.com/400x400/%s/%s" $hex $hex))
) }}
Loading

0 comments on commit 5ec0a82

Please sign in to comment.