package compose import ( "errors" "net/url" "regexp" "strings" ) // VisualKind says where a post's image comes from. Public posts carry an // image; the order of preference is the post's own visual_url (set by the // caller), then the entry's rendered portrait, then a render of the entry's // visual prompt, and only for a post with no prompt at all the campaign's // main image. type VisualKind string const ( VisualPortrait VisualKind = "portrait" VisualNeedsRender VisualKind = "needs-render" VisualFallback VisualKind = "fallback" ) // Visual is the resolved image for one post. For VisualNeedsRender, URL is // empty, Prompt is the entry's brief and Target the file the hall expects the // render at (empty if the entry names none). Such a post is not published // with the fallback: the entry asked for its own portrait. type Visual struct { Kind VisualKind `json:"kind"` URL string `json:"visual_url,omitempty"` Path string `json:"path,omitempty"` Prompt string `json:"prompt,omitempty"` Target string `json:"target,omitempty"` } var ( htmlComment = regexp.MustCompile(`(?s)`) markdownImg = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)\)`) visualTarget = regexp.MustCompile(`visuals/[A-Za-z0-9._-]+\.(?:jpe?g|png)`) // A bare file name in backticks, as draft seats name their intended render. namedTarget = regexp.MustCompile("`([A-Za-z0-9._-]+\\.(?:jpe?g|png))`") ) // ResolveVisual picks the image for a post written from entryMarkdown, which // is empty for a post that has no source entry (an outlet post). Portrait // paths are returned relative to the hall repository root ("visuals/x.png") // and joined onto hallRawBase; the caller checks that the file exists. func ResolveVisual(entryMarkdown, hallRawBase, fallbackURL string) (Visual, error) { if entryMarkdown != "" { visible := htmlComment.ReplaceAllString(entryMarkdown, "") for _, m := range markdownImg.FindAllStringSubmatch(visible, -1) { rel := portraitPath(m[1]) if rel == "" { continue } u, err := joinURL(hallRawBase, rel) if err != nil { return Visual{}, err } return Visual{Kind: VisualPortrait, URL: u, Path: rel}, nil } if body, ok := section(entryMarkdown, "Visual prompt"); ok { if prompt := promptText(body); prompt != "" { return Visual{ Kind: VisualNeedsRender, Prompt: prompt, Target: renderTarget(entryMarkdown), }, nil } } } if fallbackURL == "" { return Visual{}, errors.New("no portrait or visual prompt, and no fallback image configured") } return Visual{Kind: VisualFallback, URL: fallbackURL}, nil } // renderTarget is where the hall expects the entry's portrait: a // visuals/ path anywhere in the entry, else a backticked image file name. func renderTarget(markdown string) string { if t := visualTarget.FindString(markdown); t != "" { return t } if m := namedTarget.FindStringSubmatch(markdown); m != nil { return "visuals/" + m[1] } return "" } // portraitPath reduces an entry's image link ("../visuals/x.png") to its // path in the hall repository, or "" for a link that is not a hall visual. func portraitPath(link string) string { if strings.Contains(link, "://") { return "" } i := strings.Index(link, "visuals/") if i < 0 { return "" } return link[i:] } // promptText is the brief inside a Visual prompt section: its quoted lines // when it has any, without the quote markers; otherwise the section minus // image lines and italic status notes. func promptText(body string) string { var quoted, plain []string for _, line := range strings.Split(htmlComment.ReplaceAllString(body, ""), "\n") { t := strings.TrimSpace(line) switch { case strings.HasPrefix(t, ">"): quoted = append(quoted, strings.TrimSpace(strings.TrimPrefix(t, ">"))) case t == "", markdownImg.MatchString(t), strings.HasPrefix(t, "_"): default: plain = append(plain, t) } } if len(quoted) > 0 { return strings.TrimSpace(strings.Join(quoted, " ")) } return strings.TrimSpace(strings.Join(plain, " ")) } func joinURL(base, rel string) (string, error) { u, err := url.Parse(base) if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") { return "", errors.New("hall raw base must be an absolute http(s) URL") } u.Path = strings.TrimRight(u.Path, "/") + "/" + rel return u.String(), nil }