app/document.go
241 lines · 6512 bytes
1package app
2
3import (
4 "encoding/json"
5 "slices"
6 "strconv"
7 "strings"
8
9 "github.com/krazywarez/devianter"
10)
11
12// docNode is one node of the document format DeviantArt's current editor
13// stores: a tree of typed nodes, text leaves carrying marks. Only the fields
14// the renderer reads are declared; attrs is decoded per node type.
15type docNode struct {
16 Type string `json:"type"`
17 Text string `json:"text"`
18 Attrs json.RawMessage `json:"attrs"`
19 Marks []docMark `json:"marks"`
20 Content []docNode `json:"content"`
21}
22
23type docMark struct {
24 Type string `json:"type"`
25 Attrs struct {
26 Href string `json:"href"`
27 } `json:"attrs"`
28}
29
30// parseDocument reports whether markup is a document-format description and
31// returns its root. A JSON payload without a "document" key, such as the
32// older Draft.js form, is not one.
33func parseDocument(markup string) (docNode, bool) {
34 if len(markup) == 0 || markup[0] != '{' {
35 return docNode{}, false
36 }
37 var d struct {
38 Document *docNode `json:"document"`
39 }
40 if json.Unmarshal([]byte(markup), &d) != nil || d.Document == nil {
41 return docNode{}, false
42 }
43 return *d.Document, true
44}
45
46// deleteTrackingFromURL strips DeviantArt's outgoing-link redirector, so a
47// link goes where it says rather than through deviantart.com first.
48func deleteTrackingFromURL(u string) string {
49 return strings.TrimPrefix(u, "https://www.deviantart.com/users/outgoing?")
50}
51
52// renderDocument renders a document-format description as HTML. Every string
53// from the payload is escaped here, since the result is handed to the
54// template as trusted HTML. Unknown container nodes render their children;
55// unknown leaves render nothing.
56func renderDocument(host string, root docNode) string {
57 var b strings.Builder
58 renderDocNode(&b, host, root)
59 return b.String()
60}
61
62func renderDocNode(b *strings.Builder, host string, n docNode) {
63 children := func() {
64 for _, c := range n.Content {
65 renderDocNode(b, host, c)
66 }
67 }
68 wrap := func(opening, closing string) {
69 b.WriteString(opening)
70 children()
71 b.WriteString(closing)
72 }
73
74 switch n.Type {
75 case "text":
76 renderDocText(b, host, n)
77 case "paragraph":
78 wrap("<p>", "</p>")
79 case "heading":
80 var a struct {
81 Level int `json:"level"`
82 }
83 _ = json.Unmarshal(n.Attrs, &a)
84 // The page's own title is its h1; keep description headings below it.
85 level := min(max(a.Level+1, 2), 4)
86 tag := "h" + strconv.Itoa(level)
87 wrap("<"+tag+">", "</"+tag+">")
88 case "bulletList":
89 wrap("<ul>", "</ul>")
90 case "orderedList":
91 wrap("<ol>", "</ol>")
92 case "listItem":
93 wrap("<li>", "</li>")
94 case "blockquote":
95 wrap("<blockquote>", "</blockquote>")
96 case "codeBlock":
97 wrap("<pre>", "</pre>")
98 case "hardBreak":
99 b.WriteString("<br>")
100 case "horizontalRule":
101 b.WriteString("<hr>")
102 case "da-emote":
103 renderDocEmote(b, host, n.Attrs)
104 case "da-deviation", "da-deviation-thumb":
105 renderDocDeviation(b, host, n.Attrs)
106 case "da-gif":
107 var a struct {
108 URL string `json:"url"`
109 }
110 _ = json.Unmarshal(n.Attrs, &a)
111 if a.URL != "" {
112 b.WriteString(`<a target="_blank" href="`)
113 b.WriteString(esc(a.URL))
114 b.WriteString(`">[GIF]</a>`)
115 }
116 case "da-mention":
117 var a struct {
118 Username string `json:"username"`
119 User struct {
120 Username string `json:"username"`
121 } `json:"user"`
122 }
123 _ = json.Unmarshal(n.Attrs, &a)
124 name := a.Username
125 if name == "" {
126 name = a.User.Username
127 }
128 if name != "" {
129 b.WriteString(`<a href="`)
130 b.WriteString(esc(URLBuilder(host, "group_user", "?type=about&q=", name)))
131 b.WriteString(`">@`)
132 b.WriteString(esc(name))
133 b.WriteString("</a>")
134 }
135 default:
136 children()
137 }
138}
139
140// docMarkTags maps the inline marks to the tags that render them. textStyle
141// carries colour and font choices, which the instance's own stylesheet
142// decides, so it is not listed.
143var docMarkTags = map[string]string{
144 "bold": "b", "strong": "b",
145 "italic": "i", "em": "i",
146 "underline": "u",
147 "strike": "s", "strikethrough": "s",
148 "code": "code",
149}
150
151func renderDocText(b *strings.Builder, _ string, n docNode) {
152 var opening strings.Builder
153 var closing []string // pushed in mark order, emitted in reverse
154 for _, m := range n.Marks {
155 if m.Type == "link" && m.Attrs.Href != "" {
156 opening.WriteString(`<a target="_blank" href="`)
157 opening.WriteString(esc(deleteTrackingFromURL(m.Attrs.Href)))
158 opening.WriteString(`">`)
159 closing = append(closing, "</a>")
160 continue
161 }
162 if tag, ok := docMarkTags[m.Type]; ok {
163 opening.WriteString("<" + tag + ">")
164 closing = append(closing, "</"+tag+">")
165 }
166 }
167 b.WriteString(opening.String())
168 b.WriteString(esc(n.Text))
169 for _, c := range slices.Backward(closing) {
170 b.WriteString(c)
171 }
172}
173
174// renderDocEmote renders an emoticon through the instance's emote route. An
175// official emote names its image; when it does not, the emote code without
176// its colons is the best guess at the file name.
177func renderDocEmote(b *strings.Builder, host string, attrs json.RawMessage) {
178 var a struct {
179 Code string `json:"data-emote"`
180 Src string `json:"src"`
181 Title string `json:"title"`
182 }
183 _ = json.Unmarshal(attrs, &a)
184 src := emoticonURL(host, a.Src)
185 if src == "" {
186 if name := strings.Trim(a.Code, ":"); name != "" {
187 src = URLBuilder(host, "media", "emojitar", name, "?type=e")
188 }
189 }
190 if src == "" {
191 return
192 }
193 label := a.Title
194 if label == "" {
195 label = a.Code
196 }
197 b.WriteString(`<img src="`)
198 b.WriteString(esc(src))
199 b.WriteString(`" alt="`)
200 b.WriteString(esc(label))
201 b.WriteString(`" title="`)
202 b.WriteString(esc(label))
203 b.WriteString(`">`)
204}
205
206// renderDocDeviation renders an embedded artwork as its thumbnail linking to
207// the post on this instance, or as a text link when it has no media.
208func renderDocDeviation(b *strings.Builder, host string, attrs json.RawMessage) {
209 var a struct {
210 Deviation devianter.Deviation `json:"deviation"`
211 }
212 if json.Unmarshal(attrs, &a) != nil {
213 return
214 }
215 d := &a.Deviation
216 if !VisibleDeviation(d) {
217 return
218 }
219 link := ConvertDeviantArtURLToSkunkyArt(host, d.Url)
220 label := esc(d.Author.Username + " - " + d.Title)
221 img := ParseMedia(host, d.Media, 320)
222 if link != "" {
223 b.WriteString(`<a href="`)
224 b.WriteString(esc(link))
225 b.WriteString(`">`)
226 }
227 if img != "" {
228 b.WriteString(`<img width="50%" src="`)
229 b.WriteString(esc(img))
230 b.WriteString(`" alt="`)
231 b.WriteString(label)
232 b.WriteString(`" title="`)
233 b.WriteString(label)
234 b.WriteString(`">`)
235 } else {
236 b.WriteString(label)
237 }
238 if link != "" {
239 b.WriteString("</a>")
240 }
241}