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}