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 }