Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

toolbox.gno

7.11 Kb · 216 lines
  1// Package toolbox is the directory model behind
  2// [r/moul/toolbox](/r/moul/toolbox): a list of small gno.land utilities,
  3// each one a realm of its own, rendered as a single index.
  4//
  5// A tool is a [Tool]: a slug, the realm path that serves it, a title and one
  6// line of description. A [Catalog] holds them sorted by slug and answers the
  7// three questions a directory gets asked: what is here, what is this one, and
  8// what is tagged X.
  9//
 10// # Why a package and not just a realm
 11//
 12// The hub realm is `private = true`, which buys redeploy-in-place at a stable
 13// path and costs the ability to be imported at all: the type checker refuses
 14// any realm importing a private one (`ImportPrivateError`). So the hub cannot
 15// share a single line of code with its tools by importing them, and they
 16// cannot import it either. Everything both sides must agree about (what a tool
 17// entry looks like, how the index is rendered, how a query form is spelled)
 18// has to live in a `p/`, which is this one.
 19//
 20// # Nesting is checked on the base path, not on the full one
 21//
 22// [p/moul/nestedpkg](/p/moul/nestedpkg/v0) decides "is the caller below me" by
 23// prefixing one package path with the other. That is exactly right when
 24// neither path carries a version, which is the case for a private hub: it is
 25// redeployable, so its version could never increment and it does not carry
 26// one. It breaks the moment either side does, because
 27// `gno.land/r/moul/toolbox/usernames/v0` does not begin with
 28// `gno.land/r/moul/toolbox/v0`.
 29//
 30// [BasePath] removes a trailing `/vN` so the comparison is about the family
 31// rather than the version, and [IsUnder] and [SlugOf] are built on it. A hub
 32// and a tool may each be versioned or not, independently, and all four
 33// combinations answer the same.
 34//
 35// Live: [r/moul/toolbox](/r/moul/toolbox).
 36package toolbox
 37
 38import (
 39	"errors"
 40	"strings"
 41)
 42
 43// Errors a [Tool] or a [Catalog] returns. A /p/ package returns errors and
 44// lets the realm decide whether they are fatal; all of these are a mistake in
 45// the realm's own source rather than a value a user chose, so panicking on
 46// them is the normal handling.
 47var (
 48	// ErrEmptySlug is returned for a tool with no slug. The slug is the key
 49	// and the URL segment, so there is nothing to list it under.
 50	ErrEmptySlug = errors.New("toolbox: the slug is empty")
 51
 52	// ErrBadSlug is returned for a slug carrying anything but lowercase
 53	// letters, digits and dashes. A slug travels in a URL path and in a
 54	// markdown link, and those are the characters that need no escaping in
 55	// either.
 56	ErrBadSlug = errors.New("toolbox: the slug has a character outside [a-z0-9-]")
 57
 58	// ErrEmptyPath is returned for a tool with no package path. A directory
 59	// entry nobody can open is worse than no entry.
 60	ErrEmptyPath = errors.New("toolbox: the path is empty")
 61
 62	// ErrEmptyTitle is returned for a tool with no title.
 63	ErrEmptyTitle = errors.New("toolbox: the title is empty")
 64
 65	// ErrUnknownSlug is returned by [Catalog.Remove] for a slug it does not
 66	// hold.
 67	ErrUnknownSlug = errors.New("toolbox: no tool with that slug")
 68)
 69
 70// Tool is one entry of the directory.
 71//
 72// Every field is a string or a slice of strings on purpose. A tool realm that
 73// registers itself into a public hub hands these values across a realm
 74// boundary, and a struct allocated by the caller would be the caller's object:
 75// persisting it panics with `cannot persist object from the private realm`
 76// when the caller is private. Scalars are copied, so the hub owns everything
 77// it stores whichever side built the entry.
 78type Tool struct {
 79	// Slug is the key, the URL segment and the sort order: "usernames".
 80	Slug string
 81
 82	// Path is the package path serving the tool, with its version if it has
 83	// one: "gno.land/r/moul/toolbox/usernames".
 84	Path string
 85
 86	// Title is the human name: "Usernames".
 87	Title string
 88
 89	// Desc is one line about what the tool does. It is rendered inside a
 90	// table cell, so [Index] escapes it rather than trusting it.
 91	Desc string
 92
 93	// Tags group tools in the index. Order is preserved as written.
 94	Tags []string
 95}
 96
 97// Validate reports the first thing wrong with t, or nil.
 98func (t Tool) Validate() error {
 99	switch {
100	case t.Slug == "":
101		return ErrEmptySlug
102	case !ValidSlug(t.Slug):
103		return ErrBadSlug
104	case t.Path == "":
105		return ErrEmptyPath
106	case t.Title == "":
107		return ErrEmptyTitle
108	}
109	return nil
110}
111
112// HasTag reports whether t carries tag, compared exactly.
113func (t Tool) HasTag(tag string) bool {
114	for _, got := range t.Tags {
115		if got == tag {
116			return true
117		}
118	}
119	return false
120}
121
122// URL is the gnoweb path of the tool, the package path with the chain domain
123// removed: "/r/moul/toolbox/usernames".
124//
125// It is derived rather than stored so an entry cannot carry a link that
126// disagrees with its own path.
127func (t Tool) URL() string {
128	if i := strings.Index(t.Path, "/"); i >= 0 && !strings.HasPrefix(t.Path, "/") {
129		return t.Path[i:]
130	}
131	return t.Path
132}
133
134// ValidSlug reports whether s is a usable slug: non-empty, and lowercase
135// letters, digits and dashes only.
136//
137// It is deliberately narrower than what a package path allows. A slug ends up
138// in a URL and in markdown link syntax, and this is the set that survives both
139// untouched, so nothing downstream has to escape it.
140func ValidSlug(s string) bool {
141	if s == "" {
142		return false
143	}
144	for i := 0; i < len(s); i++ {
145		c := s[i]
146		switch {
147		case c >= 'a' && c <= 'z':
148		case c >= '0' && c <= '9':
149		case c == '-':
150		default:
151			return false
152		}
153	}
154	return true
155}
156
157// BasePath returns pkgPath with a trailing version element removed:
158// "gno.land/p/moul/toolbox/v0" becomes "gno.land/p/moul/toolbox".
159//
160// A path with no version element is returned unchanged, so calling it twice is
161// the same as calling it once.
162func BasePath(pkgPath string) string {
163	i := strings.LastIndex(pkgPath, "/")
164	if i < 0 {
165		return pkgPath
166	}
167	if !isVersion(pkgPath[i+1:]) {
168		return pkgPath
169	}
170	return pkgPath[:i]
171}
172
173// isVersion reports whether elem is a "vN" path element: a 'v' followed by at
174// least one digit and nothing else.
175func isVersion(elem string) bool {
176	if len(elem) < 2 || elem[0] != 'v' {
177		return false
178	}
179	for i := 1; i < len(elem); i++ {
180		if elem[i] < '0' || elem[i] > '9' {
181			return false
182		}
183	}
184	return true
185}
186
187// IsUnder reports whether pkgPath is a tool of the hub at hubPath, ignoring
188// both paths' versions.
189//
190// The hub itself is not under itself, and neither is a sibling whose name
191// merely starts with the hub's: "toolboxtwo" is not under "toolbox", because
192// the comparison is against the base plus a slash.
193func IsUnder(hubPath, pkgPath string) bool {
194	base := BasePath(hubPath)
195	if base == "" {
196		return false
197	}
198	return strings.HasPrefix(BasePath(pkgPath), base+"/")
199}
200
201// SlugOf returns the slug a tool at pkgPath would have under the hub at
202// hubPath, or "" when pkgPath is not under it.
203//
204// A tool nested deeper than one element has no slug: the directory is one
205// level by design, so "toolbox/a/b" is reported as not a tool rather than
206// silently listed as "a".
207func SlugOf(hubPath, pkgPath string) string {
208	if !IsUnder(hubPath, pkgPath) {
209		return ""
210	}
211	rest := strings.TrimPrefix(BasePath(pkgPath), BasePath(hubPath)+"/")
212	if strings.Contains(rest, "/") {
213		return ""
214	}
215	return rest
216}