diff --git a/core/app.go b/core/app.go index 287ba52..9718933 100644 --- a/core/app.go +++ b/core/app.go @@ -7,6 +7,7 @@ import ( "github.com/gorilla/mux" "github.com/gorilla/sessions" "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/blog/core/models/comments" "github.com/kirsle/blog/core/models/posts" "github.com/kirsle/blog/core/models/settings" "github.com/kirsle/blog/core/models/users" @@ -25,9 +26,6 @@ type Blog struct { DB *jsondb.DB - // Helper singletone - Posts *PostHelper - // Web app objects. n *negroni.Negroni // Negroni middleware manager r *mux.Router // Router @@ -41,7 +39,6 @@ func New(documentRoot, userRoot string) *Blog { UserRoot: userRoot, DB: jsondb.New(filepath.Join(userRoot, ".private")), } - blog.Posts = InitPostHelper(blog) // Load the site config, or start with defaults if not found. settings.DB = blog.DB @@ -57,6 +54,7 @@ func New(documentRoot, userRoot string) *Blog { // Initialize the rest of the models. posts.DB = blog.DB users.DB = blog.DB + comments.DB = blog.DB // Initialize the router. r := mux.NewRouter() @@ -65,6 +63,7 @@ func New(documentRoot, userRoot string) *Blog { r.HandleFunc("/logout", blog.LogoutHandler) blog.AdminRoutes(r) blog.BlogRoutes(r) + blog.CommentRoutes(r) // GitHub Flavored Markdown CSS. r.Handle("/css/gfm.css", http.StripPrefix("/css", http.FileServer(gfmstyle.Assets))) diff --git a/core/blog.go b/core/blog.go index 09619fc..e8c5ff9 100644 --- a/core/blog.go +++ b/core/blog.go @@ -91,7 +91,7 @@ func (b *Blog) PrivatePosts(w http.ResponseWriter, r *http.Request) { // PartialIndex handles common logic for blog index views. func (b *Blog) PartialIndex(w http.ResponseWriter, r *http.Request, tag, privacy string) { - v := NewVars(map[interface{}]interface{}{}) + v := NewVars() // Get the blog index. idx, _ := posts.GetIndex() @@ -317,13 +317,13 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML { filepath, err := b.ResolvePath("blog/entry.partial") if err != nil { log.Error(err.Error()) - return "[error: missing blog/entry.partial]" + return template.HTML("[error: missing blog/entry.partial]") } t := template.New("entry.partial.gohtml") t, err = t.ParseFiles(filepath.Absolute) if err != nil { log.Error("Failed to parse entry.partial: %s", err.Error()) - return "[error parsing template in blog/entry.partial]" + return template.HTML("[error parsing template in blog/entry.partial]") } meta := PostMeta{ @@ -337,7 +337,7 @@ func (b *Blog) RenderPost(p *posts.Post, indexView bool) template.HTML { err = t.Execute(&output, meta) if err != nil { log.Error(err.Error()) - return "[error executing template in blog/entry.partial]" + return template.HTML("[error executing template in blog/entry.partial]") } return template.HTML(output.String()) @@ -372,7 +372,7 @@ func (b *Blog) EditBlog(w http.ResponseWriter, r *http.Request) { switch r.FormValue("submit") { case "preview": if post.ContentType == string(MARKDOWN) { - v.Data["preview"] = template.HTML(b.RenderMarkdown(post.Body)) + v.Data["preview"] = template.HTML(b.RenderTrustedMarkdown(post.Body)) } else { v.Data["preview"] = template.HTML(post.Body) } diff --git a/core/comments.go b/core/comments.go new file mode 100644 index 0000000..2091fe2 --- /dev/null +++ b/core/comments.go @@ -0,0 +1,301 @@ +package core + +import ( + "bytes" + "fmt" + "html/template" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/gorilla/mux" + "github.com/gorilla/sessions" + "github.com/kirsle/blog/core/models/comments" + "github.com/kirsle/blog/core/models/users" +) + +// CommentRoutes attaches the comment routes to the app. +func (b *Blog) CommentRoutes(r *mux.Router) { + r.HandleFunc("/comments", b.CommentHandler) + r.HandleFunc("/comments/edit", b.EditCommentHandler) + r.HandleFunc("/comments/delete", b.DeleteCommentHandler) +} + +// CommentMeta is the template variables for comment threads. +type CommentMeta struct { + IsAuthenticated bool + ID string + OriginURL string // URL where original comment thread appeared + Subject string // email subject + Thread *comments.Thread + Authors map[int]*users.User + CSRF string + + // Cached name and email of the user. + Name string + Email string + EditToken string +} + +// RenderComments renders a comment form partial and returns the HTML. +func (b *Blog) RenderComments(session *sessions.Session, csrfToken, url, subject string, ids ...string) template.HTML { + id := strings.Join(ids, "-") + + // Load their cached name and email if they posted a comment before. + name, _ := session.Values["c.name"].(string) + email, _ := session.Values["c.email"].(string) + editToken, _ := session.Values["c.token"].(string) + + // Check if the user is a logged-in admin, to make all comments editable. + var isAdmin bool + var isAuthenticated bool + if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { + isAuthenticated = true + if userID, ok := session.Values["user-id"].(int); ok { + if user, err := users.Load(userID); err == nil { + isAdmin = user.Admin + } + } + } + + thread, err := comments.Load(id) + if err != nil { + thread = comments.New(id) + } + + // Render all the comments in the thread. + userMap := map[int]*users.User{} + for _, c := range thread.Comments { + c.HTML = template.HTML(b.RenderMarkdown(c.Body)) + c.ThreadID = thread.ID + c.OriginURL = url + c.CSRF = csrfToken + + // Look up the author username. + if c.UserID > 0 { + log.Warn("Has USERID %d", c.UserID) + if _, ok := userMap[c.UserID]; !ok { + log.Warn("not in map") + if user, err := users.Load(c.UserID); err == nil { + userMap[c.UserID] = user + log.Warn("is now!") + } + } + + if user, ok := userMap[c.UserID]; ok { + c.Name = user.Name + c.Username = user.Username + c.Email = user.Email + c.LoadAvatar() + } + } + + // Is it editable? + if isAdmin || (len(c.EditToken) > 0 && c.EditToken == editToken) { + c.Editable = true + } + + fmt.Printf("%v\n", c) + } + + // Get the template snippet. + filepath, err := b.ResolvePath("comments/comments.partial") + if err != nil { + log.Error(err.Error()) + return template.HTML("[error: missing comments/comments.partial]") + } + + // And the comment view partial. + entryPartial, err := b.ResolvePath("comments/entry.partial") + if err != nil { + log.Error(err.Error()) + return template.HTML("[error: missing comments/entry.partial]") + } + + t := template.New("comments.partial.gohtml") + t, err = t.ParseFiles(entryPartial.Absolute, filepath.Absolute) + if err != nil { + log.Error("Failed to parse comments.partial: %s", err.Error()) + return template.HTML("[error parsing template in comments/comments.partial]") + } + + v := CommentMeta{ + ID: thread.ID, + OriginURL: url, + Subject: subject, + CSRF: csrfToken, + Thread: &thread, + IsAuthenticated: isAuthenticated, + Name: name, + Email: email, + EditToken: editToken, + } + + output := bytes.Buffer{} + err = t.Execute(&output, v) + if err != nil { + log.Error(err.Error()) + return template.HTML("[error executing template in comments/comments.partial]") + } + + return template.HTML(output.String()) +} + +// CommentHandler handles the /comments URI for previewing and posting. +func (b *Blog) CommentHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + b.BadRequest(w, r, "That method is not allowed.") + return + } + v := NewVars() + currentUser, _ := b.CurrentUser(r) + editToken := b.GetEditToken(w, r) + submit := r.FormValue("submit") + + // Load the comment data from the form. + c := &comments.Comment{} + c.ParseForm(r) + if c.ThreadID == "" { + b.FlashAndRedirect(w, r, "/", "No thread ID found in the comment form.") + return + } + + // Look up the thread. + t, err := comments.Load(c.ThreadID) + if err != nil { + t = comments.New(c.ThreadID) + } + + // Origin URL to redirect them to at the end. + origin := "/" + if c.OriginURL != "" { + origin = c.OriginURL + } + + // Are we editing a post? + if r.FormValue("editing") == "true" { + id := r.FormValue("id") + c, err = t.Find(id) + if err != nil { + b.FlashAndRedirect(w, r, "/", "That comment was not found.") + return + } + + // Verify they have the matching edit token. Admin users are allowed. + if c.EditToken != editToken && !currentUser.Admin { + b.FlashAndRedirect(w, r, origin, "You don't have permission to edit that comment.") + return + } + + // Parse the extra form data into the comment struct. + c.ParseForm(r) + } + + // Are we deleting said post? + if submit == "confirm-delete" { + t.Delete(c.ID) + b.FlashAndRedirect(w, r, origin, "Comment deleted!") + return + } + + // Cache their name and email in their session. + session := b.Session(r) + session.Values["c.name"] = c.Name + session.Values["c.email"] = c.Email + session.Save(r, w) + + // Previewing, deleting, or posting? + switch submit { + case "preview", "delete": + if !c.Editing && currentUser.IsAuthenticated { + c.Name = currentUser.Name + c.Email = currentUser.Email + c.LoadAvatar() + } + c.HTML = template.HTML(b.RenderMarkdown(c.Body)) + case "post": + if err := c.Validate(); err != nil { + v.Error = err + } else { + // Store our edit token, if we don't have one. For example, admins + // can edit others' comments but should not replace their edit token. + if c.EditToken == "" { + c.EditToken = editToken + } + + // If we're logged in, tag our user ID with this post. + if !c.Editing && c.UserID == 0 && currentUser.IsAuthenticated { + c.UserID = currentUser.ID + } + + t.Post(c) + b.FlashAndRedirect(w, r, c.OriginURL, "Comment posted!") + } + } + + v.Data["Thread"] = t + v.Data["Comment"] = c + v.Data["Editing"] = c.Editing + v.Data["Deleting"] = submit == "delete" + + b.RenderTemplate(w, r, "comments/index.gohtml", v) +} + +// EditCommentHandler for editing comments. +func (b *Blog) EditCommentHandler(w http.ResponseWriter, r *http.Request) { + var ( + threadID = r.URL.Query().Get("t") + deleteToken = r.URL.Query().Get("d") + originURL = r.URL.Query().Get("o") + ) + + // Our edit token. + editToken := b.GetEditToken(w, r) + + // Search for the comment. + thread, err := comments.Load(threadID) + if err != nil { + b.FlashAndRedirect(w, r, "/", "That comment thread was not found.") + return + } + comment, err := thread.FindByDeleteToken(deleteToken) + if err != nil { + b.FlashAndRedirect(w, r, "/", "That comment was not found.") + return + } + + // And can we edit it? + if comment.EditToken != editToken { + b.Forbidden(w, r, "Your edit token is not valid for that comment.") + return + } + + comment.ThreadID = thread.ID + comment.OriginURL = originURL + + v := NewVars() + v.Data["Thread"] = thread + v.Data["Comment"] = comment + v.Data["Editing"] = true + + b.RenderTemplate(w, r, "comments/index.gohtml", v) +} + +// DeleteCommentHandler for editing comments. +func (b *Blog) DeleteCommentHandler(w http.ResponseWriter, r *http.Request) { + +} + +// GetEditToken gets or generates an edit token from the user's session, which +// allows a user to edit their comment for a short while after they post it. +func (b *Blog) GetEditToken(w http.ResponseWriter, r *http.Request) string { + session := b.Session(r) + if token, ok := session.Values["c.token"].(string); ok && len(token) > 0 { + return token + } + + token := uuid.New().String() + session.Values["c.token"] = token + session.Save(r, w) + return token +} diff --git a/core/markdown.go b/core/markdown.go index de5761a..68ade81 100644 --- a/core/markdown.go +++ b/core/markdown.go @@ -23,6 +23,5 @@ func (b *Blog) RenderMarkdown(input string) string { // Markdown pages, not for user-submitted comments or things. func (b *Blog) RenderTrustedMarkdown(input string) string { html := github_flavored_markdown.Markdown([]byte(input)) - log.Info("%s", html) return string(html) } diff --git a/core/middleware.go b/core/middleware.go index 7330712..9d58f9a 100644 --- a/core/middleware.go +++ b/core/middleware.go @@ -73,6 +73,7 @@ func (b *Blog) CurrentUser(r *http.Request) (*users.User, error) { if loggedIn, ok := session.Values["logged-in"].(bool); ok && loggedIn { id := session.Values["user-id"].(int) u, err := users.LoadReadonly(id) + u.IsAuthenticated = true return u, err } diff --git a/core/models/comments/comments.go b/core/models/comments/comments.go new file mode 100644 index 0000000..9acf4ec --- /dev/null +++ b/core/models/comments/comments.go @@ -0,0 +1,228 @@ +package comments + +import ( + "crypto/md5" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "net/mail" + "time" + + "github.com/google/uuid" + "github.com/kirsle/blog/core/jsondb" + "github.com/kirsle/golog" +) + +// DB is a reference to the parent app's JsonDB object. +var DB *jsondb.DB + +var log *golog.Logger + +func init() { + log = golog.GetLogger("blog") +} + +// Thread contains a thread of comments, for a blog post or otherwise. +type Thread struct { + ID string `json:"id"` + Comments []*Comment `json:"comments"` +} + +// Comment contains the data for a single comment in a thread. +type Comment struct { + ID string `json:"id"` + UserID int `json:"userId,omitempty"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` + Avatar string `json:"avatar"` + Body string `json:"body"` + EditToken string `json:"editToken"` + DeleteToken string `json:"deleteToken"` + Created time.Time `json:"created"` + Updated time.Time `json:"updated"` + + // Private form use only. + CSRF string `json:"-"` + Subscribe bool `json:"-"` + ThreadID string `json:"-"` + OriginURL string `json:"-"` + Subject string `json:"-"` + HTML template.HTML `json:"-"` + Trap1 string `json:"-"` + Trap2 string `json:"-"` + + // Even privater fields. + Username string `json:"-"` + Editable bool `json:"-"` + Editing bool `json:"-"` +} + +// New initializes a new comment thread. +func New(id string) Thread { + return Thread{ + ID: id, + Comments: []*Comment{}, + } +} + +// Load a comment thread. +func Load(id string) (Thread, error) { + t := Thread{} + err := DB.Get(fmt.Sprintf("comments/threads/%s", id), &t) + return t, err +} + +// Post a comment to a thread. +func (t *Thread) Post(c *Comment) error { + // If it has an ID, update an existing comment. + if len(c.ID) > 0 { + idx := -1 + for i, comment := range t.Comments { + if comment.ID == c.ID { + idx = i + break + } + } + + // Replace the comment by index. + if idx >= 0 && idx < len(t.Comments) { + t.Comments[idx] = c + DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t) + return nil + } + } + + // Assign an ID. + if c.ID == "" { + c.ID = uuid.New().String() + } + if c.DeleteToken == "" { + c.DeleteToken = uuid.New().String() + } + + t.Comments = append(t.Comments, c) + DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t) + + // TODO: handle subscriptions. + return nil +} + +// Find a comment by its ID. +func (t *Thread) Find(id string) (*Comment, error) { + for _, c := range t.Comments { + if c.ID == id { + return c, nil + } + } + return nil, errors.New("comment not found") +} + +// Delete a comment by its ID. +func (t *Thread) Delete(id string) error { + keep := []*Comment{} + var found bool + for _, c := range t.Comments { + if c.ID != id { + keep = append(keep, c) + } else { + found = true + } + } + + if !found { + return errors.New("comment not found") + } + + t.Comments = keep + DB.Commit(fmt.Sprintf("comments/threads/%s", t.ID), t) + return nil +} + +// FindByDeleteToken finds a comment by its deletion token. +func (t *Thread) FindByDeleteToken(token string) (*Comment, error) { + for _, c := range t.Comments { + fmt.Printf("%s <> %s\n", c.DeleteToken, token) + if c.DeleteToken == token { + return c, nil + } + } + + return nil, errors.New("comment not found") +} + +// ParseForm populates a Comment from a form. +func (c *Comment) ParseForm(r *http.Request) { + // Helper function to set an attribute only if the + // attribute is currently empty. + define := func(target *string, value string) { + if value != "" { + log.Info("SET DEFINE: %s", value) + *target = value + } + } + + define(&c.ThreadID, r.FormValue("thread")) + define(&c.OriginURL, r.FormValue("origin")) + define(&c.Subject, r.FormValue("subject")) + + define(&c.Name, r.FormValue("name")) + define(&c.Email, r.FormValue("email")) + define(&c.Body, r.FormValue("body")) + c.Subscribe = r.FormValue("subscribe") == "true" + + // When editing a post + c.Editing = r.FormValue("editing") == "true" + + c.Trap1 = r.FormValue("url") + c.Trap2 = r.FormValue("comment") + + // Default the timestamp values. + if c.Created.IsZero() { + c.Created = time.Now().UTC() + c.Updated = c.Created + } else { + c.Updated = time.Now().UTC() + } + + c.LoadAvatar() +} + +// LoadAvatar calculates the user's avatar for the comment. +func (c *Comment) LoadAvatar() { + // MD5 hash the email address for Gravatar. + if _, err := mail.ParseAddress(c.Email); err == nil { + h := md5.New() + io.WriteString(h, c.Email) + hash := fmt.Sprintf("%x", h.Sum(nil)) + c.Avatar = fmt.Sprintf( + "//www.gravatar.com/avatar/%s?s=96", + hash, + ) + } else { + // Default gravatar. + c.Avatar = "https://www.gravatar.com/avatar/00000000000000000000000000000000" + } +} + +// Validate checks the comment's fields for validity. +func (c *Comment) Validate() error { + // Spambot trap fields. + if c.Trap1 != "http://" || c.Trap2 != "" { + return errors.New("find a human") + } + + // Required metadata fields. + if len(c.ThreadID) == 0 { + return errors.New("you lost the comment thread ID") + } else if len(c.Subject) == 0 { + return errors.New("this comment thread is missing a subject") + } + + if len(c.Body) == 0 { + return errors.New("the message is required") + } + + return nil +} diff --git a/core/models/users/users.go b/core/models/users/users.go index 31e5981..ab74307 100644 --- a/core/models/users/users.go +++ b/core/models/users/users.go @@ -26,6 +26,8 @@ type User struct { Name string `json:"name"` Email string `json:"email"` + IsAuthenticated bool `json:"-"` + // Whether the user was loaded in read-only mode (no password), so they // can't be saved without a password. readonly bool diff --git a/core/postdb.go b/core/postdb.go deleted file mode 100644 index 508fed7..0000000 --- a/core/postdb.go +++ /dev/null @@ -1,23 +0,0 @@ -package core - -import ( - "github.com/kirsle/blog/core/jsondb" -) - -// PostHelper is a singleton helper to manage the database controls for blog -// entries. -type PostHelper struct { - master *Blog - DB *jsondb.DB -} - -// InitPostHelper initializes the blog post controller helper. -func InitPostHelper(master *Blog) *PostHelper { - return &PostHelper{ - master: master, - DB: master.DB, - } -} - -// GetIndex loads the blog index (cache). -func (p *PostHelper) GetIndex() {} diff --git a/core/templates.go b/core/templates.go index f05add1..b4a0ecd 100644 --- a/core/templates.go +++ b/core/templates.go @@ -98,16 +98,27 @@ func (b *Blog) RenderTemplate(w http.ResponseWriter, r *http.Request, path strin return err } + // The comment entry partial. + commentEntry, err := b.ResolvePath("comments/entry.partial") + if err != nil { + log.Error("RenderTemplate(%s): comments/entry.partial not found") + return err + } + // Useful template functions. - log.Error("HERE!!!") t := template.New(filepath.Absolute).Funcs(template.FuncMap{ "StringsJoin": strings.Join, "RenderPost": b.RenderPost, + "RenderComments": func(subject string, ids ...string) template.HTML { + session := b.Session(r) + csrf := b.GenerateCSRFToken(w, r, session) + return b.RenderComments(session, csrf, r.URL.Path, subject, ids...) + }, }) // Parse the template files. The layout comes first because it's the wrapper // and allows the filepath template to set the page title. - t, err = t.ParseFiles(layout.Absolute, filepath.Absolute) + t, err = t.ParseFiles(layout.Absolute, commentEntry.Absolute, filepath.Absolute) if err != nil { log.Error(err.Error()) return err diff --git a/root/blog/entry.gohtml b/root/blog/entry.gohtml index 7d38d40..3c2727b 100644 --- a/root/blog/entry.gohtml +++ b/root/blog/entry.gohtml @@ -17,7 +17,8 @@ {{ if $p.EnableComments }}

Comments

- TBD. + {{ $idStr := printf "%d" $p.ID}} + {{ RenderComments $p.Title "post" $idStr }} {{ else }}
Comments are disabled on this post. diff --git a/root/comments/comments.partial.gohtml b/root/comments/comments.partial.gohtml new file mode 100644 index 0000000..d3ecb77 --- /dev/null +++ b/root/comments/comments.partial.gohtml @@ -0,0 +1,97 @@ +{{ $t := .Thread }} +{{ $a := .Authors }} + +

+{{- if eq (len $t.Comments) 1 -}} + There is 1 comment on this page. +{{- else -}} + There are {{ len $t.Comments }} comments on this page. +{{- end -}} +Add your comment. +

+ +{{ range $t.Comments }} + {{ template "comment" . }} +{{ end }} + +

Add a Comment

+ +
+ + + + + + {{ if not .IsAuthenticated }} +
+ +
+ +
+
+ +
+ +
+ + + Used for your Gravatar + and optional thread subscription. Privacy policy. + + + +
+
+ {{ end }} + +
+ + + + You may format your message using + Markdown + syntax. + +
+ + + + + +
diff --git a/root/comments/entry.partial.gohtml b/root/comments/entry.partial.gohtml new file mode 100644 index 0000000..a737d12 --- /dev/null +++ b/root/comments/entry.partial.gohtml @@ -0,0 +1,53 @@ +{{ define "comment" }} +
+
+
+
+ Avatar image +
+
+
+ {{ if and .UserID .Username }} + {{ or .Name "Anonymous" }} + {{ else }} + {{ or .Name "Anonymous" }} + {{ end }} + + posted on {{ .Created.Format "January 2, 2006 @ 15:04 MST" }} + + {{ if .Updated.After .Created }} + + (updated {{ .Updated.Format "1/2/06 15:04 MST"}}) + + {{ end }} +
+ + {{ .HTML }} + + {{ if .Editable }} +
+ + + + + + + + + +
+ {{ end }} +
+
+
+
+{{ end }} diff --git a/root/comments/index.gohtml b/root/comments/index.gohtml new file mode 100644 index 0000000..f9247c9 --- /dev/null +++ b/root/comments/index.gohtml @@ -0,0 +1,122 @@ +{{ define "title" }}Preview Comment{{ end }} +{{ define "content" }} + +{{ with .Data.Comment }} +
+ + + + + {{ if $.Data.Editing -}} + + + {{ end }} + +

+ {{- if $.Data.Deleting -}} + Delete Comment + {{- else if $.Data.Editing -}} + Edit Comment + {{- else -}} + Preview + {{- end -}} +

+ +
+ + {{ template "comment" . }} + +
+ + {{ if $.Data.Deleting }} +

Are you sure you want to delete this comment?

+ + + Cancel + {{ else }} + {{ if not $.CurrentUser.IsAuthenticated }} +
+ +
+ {{ if and $.CurrentUser.IsAuthenticated }} + {{ $.CurrentUser.Name }} + {{ else }} + + {{ end }} +
+
+ +
+ +
+ + + Used for your Gravatar + and optional thread subscription. Privacy policy. + + + +
+
+ {{ end }} + +
+ + + + You may format your message using + Markdown + syntax. + +
+ + + + + + {{ end }} +
+{{ end }} + +{{ end }} diff --git a/root/css/blog-core.css b/root/css/blog-core.css index 7f64550..8ca42bd 100644 --- a/root/css/blog-core.css +++ b/root/css/blog-core.css @@ -23,6 +23,13 @@ a.blog-title { color: #909; } +/* Comment metadata line */ +.comment-meta { + font-style: italic; + font-size: smaller; + margin-bottom: 1rem; +} + /* Code blocks treated as
 tags */
 .markdown code {
     display: block;