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.

title.gno

5.43 Kb · 175 lines
  1package wiki
  2
  3import (
  4	"errors"
  5	"strings"
  6	"unicode"
  7
  8	"gno.land/p/nt/markdown/sanitize/v0"
  9)
 10
 11// Namespace partitions the title space the way MediaWiki does: the same name
 12// can exist once per namespace, and each namespace gets its own rendering and
 13// its own listing.
 14type Namespace uint8
 15
 16// There is deliberately no Talk namespace. Discussion is not an article with
 17// a prefix: it is an append-only thread attached to a page, which threads
 18// properly, moderates per message, and cannot be silently rewritten by whoever
 19// edits last. See talk.gno.
 20const (
 21	NSMain     Namespace = iota // articles
 22	NSUser                      // per-address pages
 23	NSCategory                  // category descriptions; membership is an index
 24	NSTemplate                  // reusable fragments
 25	NSHelp                      // documentation about the wiki itself
 26	NSSpecial                   // generated listings; never stored
 27)
 28
 29// namespaces is the canonical table. The two-character code is the avl key
 30// prefix: it is fixed-width on purpose, because ufmt in gno supports no width
 31// or padding flags (ufmt.Sprintf("%02d", 7) returns "7"), so a numeric key
 32// would sort "0","1","10","2" and silently interleave namespaces past nine.
 33var namespaces = [...]struct {
 34	prefix string // canonical display prefix, "" for main
 35	code   string // fixed-width avl key prefix
 36}{
 37	NSMain:     {"", "00"},
 38	NSUser:     {"User", "01"},
 39	NSCategory: {"Category", "02"},
 40	NSTemplate: {"Template", "03"},
 41	NSHelp:     {"Help", "04"},
 42	NSSpecial:  {"Special", "05"},
 43}
 44
 45// Title is a normalized (namespace, name) pair. The zero Title is invalid;
 46// build one with ParseTitle.
 47type Title struct {
 48	NS   Namespace
 49	Name string
 50}
 51
 52// MaxTitleLen bounds a title name in bytes, matching MediaWiki's limit.
 53const MaxTitleLen = 255
 54
 55var (
 56	ErrEmptyTitle   = errors.New("wiki: empty title")
 57	ErrTitleTooLong = errors.New("wiki: title too long")
 58	ErrTitleChar    = errors.New("wiki: illegal character in title")
 59)
 60
 61// ParseTitle normalizes a user-supplied title.
 62//
 63// The normalization is a deliberate subset of MediaWiki's: underscores become
 64// spaces, runs of whitespace collapse, the first letter is upper-cased, and a
 65// recognized "Namespace:" prefix (case-insensitive) selects the namespace.
 66// "Talk:gno  land" and "talk:Gno_land" both parse to Talk:Gno land.
 67//
 68// The accepted character set is narrower than MediaWiki's on purpose. A title
 69// is rendered inside markdown and inside a URL path, so anything that is
 70// markdown-significant ("*", "`", "[", "|", "#", "<"), path-significant ("/",
 71// "?", "%", "&") or invisible (bidi and zero-width controls) is rejected
 72// rather than escaped. Rejecting keeps Title.String, Title.Slug and the
 73// sanitized render byte-identical, which is what TestTitleCharsSurviveSanitize
 74// pins.
 75func ParseTitle(raw string) (Title, error) {
 76	s := sanitize.StripBidiAndZeroWidth(raw)
 77	s = strings.ReplaceAll(s, "_", " ")
 78	s = strings.Join(strings.Fields(s), " ")
 79	if s == "" {
 80		return Title{}, ErrEmptyTitle
 81	}
 82
 83	ns := NSMain
 84	if i := strings.Index(s, ":"); i > 0 {
 85		if n, ok := namespaceByPrefix(s[:i]); ok {
 86			ns = n
 87			s = strings.TrimSpace(s[i+1:])
 88			if s == "" {
 89				return Title{}, ErrEmptyTitle
 90			}
 91		}
 92	}
 93
 94	if len(s) > MaxTitleLen {
 95		return Title{}, ErrTitleTooLong
 96	}
 97	for _, r := range s {
 98		if !validTitleRune(r) {
 99			return Title{}, ErrTitleChar
100		}
101	}
102	return Title{NS: ns, Name: upperFirst(s)}, nil
103}
104
105// MustParseTitle is ParseTitle for titles known at development time.
106func MustParseTitle(raw string) Title {
107	t, err := ParseTitle(raw)
108	if err != nil {
109		panic(err)
110	}
111	return t
112}
113
114func namespaceByPrefix(p string) (Namespace, bool) {
115	p = strings.TrimSpace(p)
116	for i, e := range namespaces {
117		if e.prefix != "" && strings.EqualFold(e.prefix, p) {
118			return Namespace(i), true
119		}
120	}
121	return NSMain, false
122}
123
124// validTitleRune reports whether r may appear in a title name. ASCII is an
125// explicit allowlist; above ASCII only letters and digits are accepted, which
126// admits "Élysée" and "東京" while excluding every invisible control.
127func validTitleRune(r rune) bool {
128	switch {
129	case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
130		return true
131	case r == ' ' || r == '-' || r == '.' || r == ',' || r == '\'' || r == '(' || r == ')' || r == ':':
132		return true
133	case r < 0x80:
134		return false
135	default:
136		return unicode.IsLetter(r) || unicode.IsDigit(r)
137	}
138}
139
140func upperFirst(s string) string {
141	rs := []rune(s)
142	if len(rs) == 0 {
143		return s
144	}
145	rs[0] = unicode.ToUpper(rs[0])
146	return string(rs)
147}
148
149// String is the canonical display form, "Talk:Gno land".
150func (t Title) String() string {
151	if t.NS == NSMain {
152		return t.Name
153	}
154	return namespaces[t.NS].prefix + ":" + t.Name
155}
156
157// Slug is the URL form used in render paths, "Talk:Gno_land". Titles cannot
158// contain "/", so a slug is always exactly one path segment and the segment
159// after it is unambiguously a sub-route.
160func (t Title) Slug() string {
161	return strings.ReplaceAll(t.String(), " ", "_")
162}
163
164// Key is the avl key: a fixed-width namespace code followed by the name, so
165// iteration yields main-namespace articles first, then Talk, and so on, each
166// group in name order.
167func (t Title) Key() string {
168	return namespaces[t.NS].code + ":" + t.Name
169}
170
171// Prefix returns the avl key prefix that selects a whole namespace.
172func (ns Namespace) Prefix() string { return namespaces[ns].code + ":" }
173
174// String returns the namespace's display prefix, "" for the main namespace.
175func (ns Namespace) String() string { return namespaces[ns].prefix }