app/document.go

v1.5.6
skunky-art/app/document.go history · blame · raw

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}