Transaction
2392C97A6BAE36…CA4470253510
Block 271,235 · index 0 · indexed
Summary
- Hash
- 2392C97A6BAE36965423907542E1E5D7C8B98B5A38ED1C157755CA4470253510
- Block
- 271,235
- Size
- 23229 bytes
- Gas used
- 29,322,279 / 41,144,400
- Fee
- 411444ugnot
- Status
- success
Messages
- Attached funds
- 10000000ugnot
- Package
- gno.land/p/moul/kit/ui/v0
Arguments · 11
- #1ui
- #2README.md
- #3# `gno.land/p/moul/kit/ui/v0` The shared display vocabulary for `r/moul` realms: the small set of rendering decisions every realm was making on its own, made once. First package of the `p/moul/kit/*` layer ([moul/gno-contracts#151](https://github.com/moul/gno-contracts/issues/151)). `kit` composes the existing packages, it does not replace them. ## Why it exists Measured across 118 realm directories in this repo: | | | |---|---| | Realm dirs importing **zero** `p/moul/*` | 79 of 118 | | Realm lines inside `Render` / `render*` | 5,909 of 23,276 (25.4%) | | Lines in duplicated private helpers | 857 across 126 functions | | Copies of `shortAddr` | 11, with **four different truncation rules** | | Copies of `escapeInline` | 8, all a byte-identical seven-pair replacer | The `shortAddr` number is the one that matters: the same account rendered differently depending on which realm you opened. That is not duplication, it is four answers to one question. ## What is here, and what is deliberately not This package does **not** re-export markdown primitives. Headings, bold, lists, code blocks and links already have an owner in [`p/moul/md`](https://github.com/moul/gno-contracts/tree/main/p/moul/md); import that alongside. What lives here is only what had no owner and was therefore copy-pasted. ```go import ( "gno.land/p/moul/kit/ui/v0" "gno.land/p/moul/md/v0" ) func Render(path string) string { t := ui.NewTable("#", "Player", "Score") for i, p := range players { t.Row(strconv.Itoa(i+1)+ui.Podium(i), ui.Addr(p.addr), ui.Cell(p.label)) } return md.H1("Leaderboard") + t.OrEmpty("Nobody has played yet.") } ``` | | | |---|---| | `Addr(a)` | `` `g1manfre…dlf5` ``, the one address format | | `AddrFull(a)` | the full address, in backticks | | `AddrText(a)` | shortened, no backticks, for a link title | | `AddrOf(s)` | `Addr` for an address already in string form (an avl key) | | `Short(s)`, `ShortN(s, head, tail)` | the same rule for any string: a URL, a handle, a commitment hash | | `Inline(s)`, `Cell(s)` | escape user text, delegated to [`p/nt/markdown/sanitize`](/p/nt/markdown/sanitize/v0) | | `Excerpt(s, width)` | a preview of user prose: cut to `width` runes, then escape. That order, because escaping first and cutting second strands a backslash | | `Action(title, fn, args...)` | a clickable call, instead of prose telling the reader to type a function name | | `ActionIn(pkgPath, title, fn, args...)` | the same, against another realm | | `NewTable(headers...)`, `.Row(...)`, `.String()`, `.OrEmpty(msg)` | GFM tables | | `Empty(msg)` | the italic placeholder where a list would be | | `Podium(rank)` | 🥇🥈🥉, or `""` past third | | `Join(sep, parts...)` | concatenate, skipping empty sections | ## The escaping contract Table cells and `Action` titles are **markdown, not plain text**. - Anything that came from a user goes through `Cell` (inside a table) or `Inline` (anywhere else) before it reaches this package. - Output of `Addr`, `AddrFull`, `AddrOf` and `Podium` is already safe and must **not** be escaped again. `Table` renders the GFM table itself rather than delegating to [`p/moul/mdtable`](https://github.com/moul/gno-contracts/tree/main/p/moul/mdtable), which unconditionally rewrites `|` to `|` in every cell. Stacked on `Cell`, which already emits the GFM escape `\|`, that double-escapes into a stray backslash (`a\|b`, found while porting `guestbook`). One escaping stage is the only way to get this right, and it has to be the stage that knows whether the text is user input. `Action` also escapes its title, which [`p/moul/helplink`](https://github.com/moul/gno-contracts/tree/main/p/moul/helplink) does not (it carries an `// XXX: escape title` where this would go). ## Design rule **The safe, conventional thing must be the shortest thing to type.** A realm author reaching for the obvious call has to land on the correct behaviour; that is the only mechanism that stops these helpers from being rewritten a twelfth time. <!-- BEGIN GNOCONTRACTS FOOTER (generated by `make readmes`; do not edit below) --> --- Part of **[moul/gno-contracts](https://github.com/moul/gno-contracts)** — moul's versioned gno.land contracts. See the repository for the full catalog, build/test tooling, and usage. **Dependency graph:**  > ⚠️ **Disclaimer:** provided as-is, without warranty; not security-audited. Full disclaimer: [DISCLAIMER](https://github.com/moul/gno-contracts/blob/main/DISCLAIMER.md). <!-- END GNOCONTRACTS FOOTER -->
- #4gnomod.toml
- #5module = "gno.land/p/moul/kit/ui/v0" gno = "0.9"
- #6ui.gno
- #7// Package ui is the shared display vocabulary for r/moul realms: the small set // of rendering decisions that every realm was making on its own, made once. // // It deliberately does NOT re-export markdown primitives. Headings, bold, // lists, code blocks and links already have an owner in // [p/moul/md](/p/moul/md/v0); import that alongside this package. What lives // here is only what had no owner and was therefore copy-pasted: // // - Addr: one address shortening for the whole namespace. Eleven realms had // their own shortAddr with four different truncation rules, so the same // account rendered differently depending on which realm you opened. // - Inline / Cell / Excerpt: markdown escaping, delegated to // [p/nt/markdown/sanitize](/p/nt/markdown/sanitize/v0). Eight realms // hand-rolled a seven-pair strings.NewReplacer that misses most of what // the real escaper handles. Excerpt adds the length cap those previews // also need, in the order that is safe: cut, then escape. // - Action: a clickable call link instead of prose telling the reader to go // type a function name. // - Table, Empty, Podium: the last recurring scraps. // // # Escaping contract // // Table cells and Action titles are markdown, not plain text. Anything that // came from a user must go through [Cell] (inside a table) or [Inline] // (anywhere else) before it is handed to this package. Output of [Addr], // [AddrFull] and [Podium] is already safe and must NOT be escaped again. package ui import ( "strings" "gno.land/p/moul/helplink/v0" "gno.land/p/moul/md/v0" "gno.land/p/nt/markdown/sanitize/v0" ) // Ellipsis is the character placed between the kept head and tail of a // shortened string. const Ellipsis = "…" // Head and Tail are how many characters [Addr] and [Short] keep on each side. // // A gno.land address is 40 characters ("g1" plus 38 of bech32 data), so // 8+1+4 renders it as g1manfre…dlf5: enough of the head to recognise a // familiar account, enough of the tail to tell two similar ones apart. const ( Head = 8 Tail = 4 ) // minShorten is the length below which shortening cannot save a character: // Head + len(Ellipsis) + Tail. Strings at or under it are returned unchanged, // which is why the rule never produces output longer than its input. const minShorten = Head + 1 + Tail // Addr renders an address shortened and in backticks: `g1manfre…dlf5`. // // This is the default way to show an address. It is monospace (an address is // opaque data, not prose), and the backticks make it inert markdown, so it is // safe in a table cell or anywhere else without further escaping. func Addr(a address) string { return md.InlineCode(AddrText(a)) } // AddrFull renders an address in full, in backticks. Use it where the reader // needs to copy the value; use [Addr] everywhere else. func AddrFull(a address) string { return md.InlineCode(a.String()) } // AddrText renders an address shortened, with no backticks. Use it inside a // link title or another construct where backticks would not render. func AddrText(a address) string { return Short(a.String()) } // AddrOf is [Addr] for an address already in its string form, which is how // realms hold one when it is an avl key or a stored field. func AddrOf(s string) string { return md.InlineCode(Short(s)) } // Short shortens any string with the house rule: unchanged when shortening // would not save a character, otherwise head + ellipsis + tail. // // It is the same rule [Addr] uses, exposed for the non-address strings realms // also truncate: URLs, handles, commitment hashes. func Short(s string) string { return ShortN(s, Head, Tail) } // ShortN is [Short] with an explicit head and tail. A negative head or tail is // treated as zero. When head+tail cannot save a character against the input, // the input is returned unchanged. func ShortN(s string, head, tail int) string { if head < 0 { head = 0 } if tail < 0 { tail = 0 } // Count runes, not bytes: slicing a multi-byte string by byte offset // splits a rune and emits replacement characters. r := []rune(s) if len(r) <= head+1+tail { return s } return string(r[:head]) + Ellipsis + string(r[len(r)-tail:]) } // Inline escapes user-supplied text for an inline markdown context: a // sentence, a list item, a link title. // // It delegates to sanitize.InlineText, which strips bidi and zero-width // characters, folds newlines to spaces so the text cannot escape its line, // and applies the full CommonMark inline escape. Never concatenate // user-supplied text into rendered output without this. func Inline(s string) string { return sanitize.InlineText(s) } // Cell escapes user-supplied text for a markdown table cell: [Inline] plus // tab and pipe handling, so the value cannot open a new column. func Cell(s string) string { return sanitize.TableCell(s) } // Excerpt is [Inline] for a string too long to show whole: it keeps the first // width runes, appends [Ellipsis], and escapes what it kept. // // Use it for a preview of user-written prose where only the beginning carries // meaning: the first line of a post in an index, a note on a board, a comment // in a list. For an identifier whose tail has to stay recognisable, an // address, a hash, a URL, use [Short] instead, which keeps both ends. // // The order is the whole point, and it is what a call site gets wrong. // Escaping inserts backslashes, so cutting an ALREADY-escaped string can // strand a trailing lone backslash that escapes whatever chrome follows it. // Excerpt cuts first, on a rune boundary so a multi-byte character is never // split, then escapes. [Ellipsis] stays outside the escaper: it is this // package's chrome, not the user's text. func Excerpt(s string, width int) string { if width < 0 { width = 0 } r := []rune(s) // The same threshold as [ShortN] with no tail: cutting is only worth doing // when it saves a character, so a string one rune over is kept whole. if len(r) <= width+1 { return Inline(s) } return Inline(string(r[:width])) + Ellipsis } // Action renders a clickable call to a function of the current realm. // // ui.Action("Play cell 4", "Move", "gameID", "7", "cell", "4") // // Arguments are key/value pairs, as in p/moul/txlink. The title is escaped, // which is the one thing helplink.Func does not do. func Action(title, fn string, args ...string) string { return helplink.Func(Inline(title), fn, args...) } // ActionIn is [Action] against another realm, given as the full package path // that realm declares on the module line of its gnomod.toml. // // This doc deliberately contains no example path, not even a placeholder one. // gnopm scans doc comments the way it scans imports, so any package path // spelled out in a comment becomes a dependency of the package holding it: one // that is neither live nor in the workspace BLOCKS publishing this package and // everything that imports it, and naming a live one is no better, since it // drags an unrelated realm into every publish plan. This comment previously // named a path that deversioning had already removed, which blocked the whole // ui dependency tree on every chain. func ActionIn(pkgPath, title, fn string, args ...string) string { return helplink.Realm(pkgPath).Func(Inline(title), fn, args...) } // Empty renders the placeholder shown where a list would be: an italic line, // newline-terminated, so it drops into a Render body as-is. // // ui.Empty("No games yet.") // "_No games yet._\n" // // The message is realm chrome, not user input, and is not escaped. func Empty(msg string) string { return md.Italic(msg) + "\n" } // Podium returns the medal for a zero-based rank, or "" past third place. func Podium(rank int) string { switch rank { case 0: return "\U0001F947" // 🥇 case 1: return "\U0001F948" // 🥈 case 2: return "\U0001F949" // 🥉 default: return "" } } // Table accumulates markdown table rows and renders them. // // Cells are markdown, not plain text: pass [Addr] and friends straight // through, and wrap anything user-supplied in [Cell] first. // // Table renders the GFM table itself rather than delegating to // [p/moul/mdtable](/p/moul/mdtable/v0), which unconditionally rewrites "|" to // "|" in every cell. Stacked on [Cell], which already emits the GFM // escape "\|", that double-escapes into a stray backslash ("a\|b"). One // escaping stage is the only way to get this right, and it has to be the one // that knows whether the text is user input. // // t := ui.NewTable("#", "X", "O", "Status") // t.Row("7", ui.Addr(x), ui.Addr(o), "Turn: X") // return t.String() type Table struct { headers []string rows [][]string } // NewTable starts a table with the given header cells. func NewTable(headers ...string) *Table { return &Table{headers: headers} } // Row appends a row and returns the table, so calls can be chained. // // A row with fewer cells than there are headers is padded with empty cells; a // longer row is kept as-is, which renders as a ragged table rather than // silently dropping data. func (t *Table) Row(cells ...string) *Table { if n := len(t.headers); len(cells) < n { padded := make([]string, n) copy(padded, cells) cells = padded } t.rows = append(t.rows, cells) return t } // Len reports how many rows have been added. func (t *Table) Len() int { return len(t.rows) } // String renders the table. A table with no rows renders as "", so a caller // can fall back to [Empty] with a single check on [Table.Len]. func (t *Table) String() string { if len(t.rows) == 0 { return "" } var sb strings.Builder sb.WriteString("| " + strings.Join(t.headers, " | ") + " |\n") sb.WriteString("|" + strings.Repeat(" --- |", len(t.headers)) + "\n") for _, r := range t.rows { sb.WriteString("| " + strings.Join(r, " | ") + " |\n") } return sb.String() } // OrEmpty renders the table, or the placeholder when it has no rows. func (t *Table) OrEmpty(msg string) string { if len(t.rows) == 0 { return Empty(msg) } return t.String() } // Join concatenates parts, skipping empty ones, with sep between them. It is // the small piece of glue every Render needs to assemble optional sections // without emitting stray separators. func Join(sep string, parts ...string) string { kept := make([]string, 0, len(parts)) for _, p := range parts { if p != "" { kept = append(kept, p) } } return strings.Join(kept, sep) }
- #8ui_test.gno
- #9package ui import ( "strings" "testing" "gno.land/p/nt/testutils/v0" "gno.land/p/nt/uassert/v0" ) func TestShortN(t *testing.T) { cases := []struct { name string in string head, tail int want string }{ {"empty", "", 8, 4, ""}, {"shorter than the rule", "abc", 8, 4, "abc"}, {"exactly at the threshold", "0123456789abc", 8, 4, "0123456789abc"}, {"one over the threshold", "0123456789abcd", 8, 4, "01234567…abcd"}, {"gno address", "g1manfred47kzduec920z88wfr64ylksmdcedlf5", 8, 4, "g1manfre…dlf5"}, {"head only", "0123456789abcd", 6, 0, "012345…"}, {"tail only", "0123456789abcd", 0, 4, "…abcd"}, {"negative clamps to zero", "0123456789abcd", -3, -3, "…"}, {"multibyte is not split", "éééééééééééééé", 2, 2, "éé…éé"}, } for _, tc := range cases { got := ShortN(tc.in, tc.head, tc.tail) uassert.Equal(t, tc.want, got, tc.name) } } // ShortN must never make a string longer: that is the property the four // divergent shortAddr implementations disagreed on. func TestShortNeverGrows(t *testing.T) { inputs := []string{"", "a", "abcdefghijkl", "0123456789abc", "0123456789abcd", strings.Repeat("x", 100)} for _, in := range inputs { got := Short(in) uassert.True(t, len([]rune(got)) <= len([]rune(in)), "Short("+in+") grew the input") } } func TestAddr(t *testing.T) { a := testutils.TestAddress("alice") full := a.String() uassert.Equal(t, "`"+full+"`", AddrFull(a)) uassert.Equal(t, Short(full), AddrText(a)) uassert.Equal(t, "`"+Short(full)+"`", Addr(a)) // The shortened form keeps the recognisable head and the distinguishing tail. uassert.True(t, strings.HasPrefix(AddrText(a), full[:Head]), "head is kept") uassert.True(t, strings.HasSuffix(AddrText(a), full[len(full)-Tail:]), "tail is kept") uassert.True(t, strings.Contains(AddrText(a), Ellipsis), "ellipsis is present") } // Two different addresses must not collide once shortened, or the display is // worse than useless. func TestAddrOf(t *testing.T) { a := testutils.TestAddress("alice") uassert.Equal(t, Addr(a), AddrOf(a.String())) } func TestAddrDistinguishes(t *testing.T) { seen := map[string]bool{} for _, name := range []string{"alice", "bob", "carol", "dave", "eve"} { s := AddrText(testutils.TestAddress(name)) uassert.False(t, seen[s], "shortened address collision on "+name) seen[s] = true } } func TestInlineEscapes(t *testing.T) { // The seven pairs the hand-rolled escapeInline copies covered. for _, meta := range []string{"*", "_", "[", "]", "`", "\\"} { got := Inline("a" + meta + "b") uassert.True(t, strings.Contains(got, "\\"+meta), "Inline did not escape "+meta) } // And the one they all missed: a newline lets user text escape its line. uassert.False(t, strings.Contains(Inline("a\nb"), "\n"), "Inline must fold newlines") } func TestCellEscapesPipe(t *testing.T) { got := Cell("a|b") uassert.False(t, strings.Contains(got, " |"), "a bare pipe would open a new column") uassert.True(t, strings.Contains(got, "\\|"), "pipe must be escaped") } func TestEmpty(t *testing.T) { uassert.Equal(t, "*No games yet.*\n", Empty("No games yet.")) } func TestPodium(t *testing.T) { cases := []struct { rank int want string }{ {0, "\U0001F947"}, {1, "\U0001F948"}, {2, "\U0001F949"}, {3, ""}, {99, ""}, {-1, ""}, } for _, tc := range cases { uassert.Equal(t, tc.want, Podium(tc.rank)) } } func TestTable(t *testing.T) { tbl := NewTable("#", "who", "score") uassert.Equal(t, 0, tbl.Len()) uassert.Equal(t, "", tbl.String()) uassert.Equal(t, "*nobody yet.*\n", tbl.OrEmpty("nobody yet.")) tbl.Row("1", "`g1abc…wxyz`", "42").Row("2", "`g1def…uvwx`", "7") uassert.Equal(t, 2, tbl.Len()) want := "| # | who | score |\n" + "| --- | --- | --- |\n" + "| 1 | `g1abc…wxyz` | 42 |\n" + "| 2 | `g1def…uvwx` | 7 |\n" uassert.Equal(t, want, tbl.String()) uassert.Equal(t, want, tbl.OrEmpty("nobody yet.")) } // A short row is padded rather than producing a table the renderer misreads. func TestTableShortRowIsPadded(t *testing.T) { tbl := NewTable("a", "b", "c") tbl.Row("1") uassert.Equal(t, "| a | b | c |\n| --- | --- | --- |\n| 1 | | |\n", tbl.String()) } // A cell escaped with Cell must survive Table untouched: stacking Cell on an // escaper that also rewrites "|" produced "a\\|b" in the guestbook port. func TestTableDoesNotDoubleEscape(t *testing.T) { tbl := NewTable("msg") tbl.Row(Cell("a|b")) got := tbl.String() uassert.True(t, strings.Contains(got, `a\|b`), "expected the GFM pipe escape, got: "+got) uassert.False(t, strings.Contains(got, "|"), "cell was escaped twice: "+got) } func TestJoin(t *testing.T) { uassert.Equal(t, "a\n\nb", Join("\n\n", "a", "", "b")) uassert.Equal(t, "", Join("\n\n", "", "")) uassert.Equal(t, "solo", Join("\n\n", "solo")) } // Action must produce a markdown link whose target carries the function name // and the key/value arguments, and must escape the title (which helplink // itself does not). func TestAction(t *testing.T) { got := Action("Play cell 4", "Move", "gameID", "7", "cell", "4") uassert.True(t, strings.HasPrefix(got, "[Play cell 4]("), "expected a markdown link, got: "+got) uassert.True(t, strings.Contains(got, "Move"), "link must name the function") uassert.True(t, strings.Contains(got, "gameID=7"), "link must carry the args") uassert.True(t, strings.Contains(got, "cell=4"), "link must carry the args") esc := Action("a*b", "Fn") uassert.True(t, strings.HasPrefix(esc, "[a\\*b]("), "title must be escaped, got: "+esc) } func TestActionIn(t *testing.T) { got := ActionIn("gno.land/r/moul/gns/v0", "Say hi", "Hello", "name", "moul") uassert.True(t, strings.Contains(got, "/r/moul/gns/v0"), "must target the named realm, got: "+got) uassert.True(t, strings.Contains(got, "Hello"), "must name the function") } func TestExcerpt(t *testing.T) { cases := []struct { name string in string width int want string }{ {"empty", "", 10, ""}, {"shorter than the width", "hello", 10, "hello"}, {"one over is kept whole, as ShortN does", "0123456789a", 10, "0123456789a"}, {"two over is cut", "0123456789ab", 10, "0123456789…"}, {"zero width", "hello", 0, "…"}, {"negative width clamps to zero", "hello", -3, "…"}, // The cut counts runes. A byte slice at 10 would split the 11th 'é' // and put invalid UTF-8 on the page. {"multibyte is not split", strings.Repeat("é", 20), 10, strings.Repeat("é", 10) + "…"}, // The escaping half: what is kept is escaped, the ellipsis is not. {"escapes what it keeps", "[link](url) and more", 11, "\\[link\\]\\(url\\)…"}, {"newline cannot escape the line", "one\ntwo", 10, "one two"}, } for _, tc := range cases { uassert.Equal(t, tc.want, Excerpt(tc.in, tc.width), tc.name) } } // Excerpt must never leave a trailing lone backslash: that is the bug that // comes from escaping first and cutting second, and it escapes whatever the // caller appends after the excerpt. func TestExcerptNeverStrandsABackslash(t *testing.T) { // Each of these ends, at some width, on a character the escaper backslashes. for _, in := range []string{ "....................", "********************", "[[[[[[[[[[[[[[[[[[[[", "!!!!!!!!!!!!!!!!!!!!", "<<<<<<<<<<<<<<<<<<<<", "--------------------", } { for width := 0; width <= 15; width++ { got := Excerpt(in, width) body := strings.TrimSuffix(got, Ellipsis) trailing := 0 for i := len(body) - 1; i >= 0 && body[i] == '\\'; i-- { trailing++ } uassert.Equal(t, 0, trailing%2, "odd run of trailing backslashes in Excerpt("+in+")") } } }
- #10/gno.MemPackageType
- #11 MPUserAll
Result log
msg:0,success:true,log:,events:[]