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

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 }