Rich Comment Formatting

Comments are an important part of a blog. Too often blogs are used as a soapbox for one person to state their ideas and opinions out into the open air of the internet without any sort of inline interaction. Comments allow an audience to give feedback, debate the points, or provide their own views back at the original author and to the general public. Without any sort of inline interaction a blog is just another semi-static website.

I added comments to my blog a long time ago, around 2009, Since then over 200 different people have added 600 thoughts, ideas, and feedback, all of which has been valuable additions to the original post (aside from the occasional spam post). There was still one thing that bugged me - I had very strict rules in place about what was acceptable content in a comment. I didn't allow any html tags, not even bold or italics, and even stripped out new lines. It was about time that I spruced up the comments a bit and let a few more things filter through.

The first thing I did was look at how other popular blogging frameworks dealt with comments. Most of them allowed certain html tags and stripped out the rest.

Note: I did entertain the idea of using bbcode, or something similar, to handle markup. I decided against this as I figured that more users out there understand html than bbcode and hey, why force people to learn when you're trying to encourage them to share?

  1. <a> -> clickable links, often with a rel=nofollow to avoid seo issues

  2. <b> -> bold words

  3. <i> -> italic words

This seemed like a good start. I did want to add one more tag, <pre>, in case anyone wanted to add code to a comment. This tag just changes up the line break behavior and (depending on your stylesheet) can also add monospacing to the font. So now I had a good start for what tags to use, but there was one more thing to thing about.

While some people understand what html tags are, there are plenty that still don't use them on a day-to-day basis. I didn't want to ignore them with this update. I decided to add two more additional rules. First, if a link gets passed in I wanted autolink it - that is, wrap it in an <a> tag so its clickable. The other thing I wanted to do was add breaks whenever someone hit the 'enter' key (AKA made a new line). This would let users who don't know HTML still add rich, readable comments.

So, to sum things up, I wanted comments to be processed as so.

  1. Check to make sure that acceptable tags where in an acceptable format*

  2. Strip out any additional tags

  3. Add extra syntax (autolink and line breaks) to content

  4. *acceptable format = no extra attributes

My first thought was to just make an easy procedural script to do this. However, this didn't work. Doing this type of processing is much easier if you use placeholders and remove the acceptable tags all together (as you'll see in the code below). Creating a placeholder, using it, and saving the placeholder & replacement is too much logic to stuff into a few functions. So I created a quick and dirty utility class that I'm still not totally excited about even though it works just fine for my use cases.

  1. class CleanComment

  2. {

  3. private static $LINK_PATTERN =

  4. '@<a.*href=["\']([^"\']*)["\'].*>(.*)</a>@i';

  5. private static $BOLD_PATTERN =

  6. '@<b.*>(.*)</b>@i';

  7. private static $ITALIC_PATTERN =

  8. '@<i.*>(.*)</i>@i';

  9. private static $CODE_PATTERN =

  10. '@<pre[^>]*>(.*)</pre>@is';

  11. private static $LINK_REPLACE =

  12. '<a href="%s" rel="nofollow" target="_blank">%s</a>';

  13. private static $BOLD_REPLACE =

  14. '<b>%s</b>';

  15. private static $ITALIC_REPLACE =

  16. '<i>%s</i>';

  17. private static $CODE_REPLACE =

  18. '<pre>%s</pre>';

  19. private static $URL_PATTERN =

  20. '@(https?://[a-z0-9\.-]+\.[a-z]{2,6}[^\s]*[^\.,\?\!;\s]+)@i';

  21. private static $LINE_BREAK_PATTERN = '@([\r\n]+)@';

  22. private static $LINE_BREAK_REPLACE = '<br />';

  23. private $replacement_array = array();

  24. public function __construct() {}

  25. public function activate($content)

  26. {

  27. $content = $this->process_element(

  28. $content, self::$CODE_PATTERN, self::$CODE_REPLACE);

  29. $content = $this->process_element(

  30. $content, self::$LINK_PATTERN, self::$LINK_REPLACE);

  31. $content = $this->process_element(

  32. $content, self::$ITALIC_PATTERN, self::$ITALIC_REPLACE);

  33. $content = $this->process_element(

  34. $content, self::$BOLD_PATTERN, self::$BOLD_REPLACE);

  35. $content = $this->strip_extra_tags($content);

  36. $content = $this->link_unlinked_urls(

  37. $content, self::$URL_PATTERN, self::$LINK_REPLACE);

  38. $content = $this->add_line_breaks($content);

  39. $content = $this->replace_element_patterns($content);

  40. return $content;

  41. }

  42. private function process_element($content, $pattern, $replace)

  43. {

  44. $match_count = preg_match_all(

  45. $pattern, $content, $matches, PREG_SET_ORDER);

  46. if($match_count < 1)

  47. return $content;

  48. foreach($matches as $match)

  49. {

  50. $full_match = array_shift($match);

  51. $placeholder = $this->create_placeholder($full_match);

  52. $full_match_pattern = $this->create_full_match_pattern($full_match);

  53. $content = preg_replace(

  54. $full_match_pattern, $placeholder, $content, 1);

  55. $this->replacement_array[$placeholder] = vsprintf($replace, $match);

  56. }

  57. return $content;

  58. }

  59. private function create_placeholder($text)

  60. {

  61. return md5($text . rand());

  62. }

  63. private function create_full_match_pattern($text)

  64. {

  65. $pattern = '';

  66. $pattern .= '@';

  67. $pattern .= preg_quote($text, '@');

  68. $pattern .= '@';

  69. $pattern .= 'i';

  70. return $pattern;

  71. }

  72. private function strip_extra_tags($content)

  73. {

  74. return strip_tags($content);

  75. }

  76. private function link_unlinked_urls($content, $pattern, $replace)

  77. {

  78. $match_count = preg_match_all(

  79. $pattern, $content, $matches, PREG_SET_ORDER);

  80. if($match_count < 1)

  81. return $content;

  82. foreach($matches as $match)

  83. {

  84. $full_match = array_shift($match);

  85. $full_match_pattern = $this->create_full_match_pattern($full_match);

  86. $replace = sprintf($replace, $match[0], $match[0]);

  87. $content = preg_replace(

  88. $full_match_pattern, $replace, $content, 1);

  89. }

  90. return $content;

  91. }

  92. private function add_line_breaks($content)

  93. {

  94. return preg_replace(

  95. self::$LINE_BREAK_PATTERN, self::$LINE_BREAK_REPLACE, $content);

  96. }

  97. private function replace_element_patterns($content)

  98. {

  99. foreach($this->replacement_array as $key => $replace)

  100. {

  101. $content = str_replace($key, $replace, $content);

  102. }

  103. return $content;

  104. }

  105. }

I'm sure that some of the regular expressions will need to get modified as different URLs start coming through. This class worked just dandy for all my existing comments, though.

Using this code I went back and processed all of the comments, adding a new 'format' for each one so I could compare the raw user comment to the processed one. Everything looked and worked great, especially for some of the longer paragraphs that some of the fellow hikers have left describing different areas and experiences. It's also in the codebase now (modified a bit to fit in the stack) and I'm saving two versions of every comment, the raw and the processed, just in case something wonky happens.

So now you can read and add richer comments to the blog! Enjoy :)