내용이 길어져 어쩔 수 없이 두 개의 글로 나누게 되었다. 앞선 글에서는 모두 사이트 저장소 바깥에서 해야 할 일들을 했고, 이제 사이트 저장소를 수정해주면 된다.

_config.yml 수정하기

우선 _config.yml에서 comments: 아래의 provider"staticman_v2"로 바꾸어 staticman_v2를 댓글 작성에 사용할 것임을 선언한다. 또 staticman: 아래 항목을

staticman:
    branch      : "main" 혹은 "master"
    endpoint    : "https://[[Heroku  이름]].herokuapp.com/v2/entry/"

로 입력해준다. 이 때 branch 태그는 본인 사이트 저장소의 메인 브랜치가 main이면 main으로, master면 master로 입력하면 된다.

바로 밑의 reCaptha의 siteKey는 reCaptcha에서 나온 그대로 입력하면 된다. 다만 공개된 파일인 _config.yml에 비밀키를 그대로 입력할 경우 github 저장소를 통해 모든 사람이 비밀키를 알 수 있게 된다.

따라서 아까 만든 RSA 키를 가지고 비밀키를 암호화한다. 다음 url

https://[[Heroku 앱 이름]].herokuapp.com/v2/encrypt/[[reCaptcha 비밀키]]

으로 이동하면 한 줄짜리 키가 적힌 웹페이지가 나오는데, 이것이 바로 앞서 입력해둔 RSA 키를 이용해 암호화된 reCaptcha 비밀키다. 이를 secret에 붙여넣는다.

reCaptcha:
  siteKey                : "6LeiUpkgAAAAAN3VR6VD-g9b10-yf2r8DjRmfiVZ"
  secret                 : "AE8k5EQKTRnYyp/vpHoMUhAH/YVkzv36tfI+ZZZ2N5c/pFI7Afio0TQfD/FJdDUOpJ8UH+n5K1x7Yeqc5tbcfG20rpKpWCXTiehLJSqiMixuj12oPGzg7iD4Qecehc9o02vSk6pRS/lCAoxuO2GU9zTs8EyKYSqgCkLIKEzoZDnnVJKvpWoPhhpqmaogaYG0AvuLyoKwDoN1C8EwZlGg69btCtVgtcIWgCscVb4eOlc/TH+b3FbKAn2XfLlRmRaDhGpsAl9HMWoIFURnqQ4ZDkcD3S6H9tGNrJd1uUtsWuhnXz7iz5nvg2Z82aWqOD8hqAaCH/jLpKWFNJiFVGqjgzVmxn665k9NCGmPx7rFPUkmQN8MFjJi1WO+w1WmUTdf0N6+A253f9Ls5ZKqJHRsoXDubd9c9bTuFI1YmaTiXiU45vJXMQRTgqO2XgWSbIANigWC4fQXa6D42pFZMVlOs9zjQ2dww84hMfNjqA1djAGf7C4oWBMNZqzyDiPbWvwBFZ1MQqSBq1SWrrrxF1l501U9GrYaOpgFlKBm0d1l4BE5Wft176EOzkyGGvgzhKfWMDla8CrOAQD8Qglpz3chIMAVTXucCIjvK74yNuNloZsXcGqGaFQguNsa9EhiAhWwviHFQmVCcxOl2bbMuc1QN0qsQC1TO1J0cQs9kbfWd8c="

이런 식으로 되어 있다.

staticman.yml 수정

Staticman의 설정들은 사이트 저장소의 루트 디렉터리에 있는 staticman.yml파일을 통해 이뤄진다. 이 파일이 없다면 적당한 아무 사이트, 예컨대 minimal-mistakes 테마의 github 저장소에서 받을 수 있다. 코드를 찬찬히 뜯어보자.

  # (*) REQUIRED
  #
  # Names of the fields the form is allowed to submit. If a field that is
  # not here is part of the request, an error will be thrown.
  allowedFields: ["name", "message", "replying_to"]

  # (*) REQUIRED WHEN USING NOTIFICATIONS
  #
  # When allowedOrigins is defined, only requests sent from one of the domains
  # listed will be accepted. The origin is sent as part as the `options` object
  # (e.g. <input name="options[origin]" value="http://yourdomain.com/post1")
  # allowedOrigins: ["yourdomain.com"]

allowedFields는 댓글 폼에서 받을 항목들을 나타낸다. 나의 경우는 이름과 메시지만 적도록 하였다. "replying_to"의 경우 수동으로 입력하게 되어있는 항목은 아니고, 답글을 달 때 어떤 댓글에 대한 답글인지를 표시하는 정보이다.

그 밑의 주석처리 되어있는 줄은 이메일을 통해 알림을 받을 때 필요하다. 이는 Mailgun을 통해 이뤄지는데, 나는 사용하지 않기 때문에 그대로 냅뒀다.

  # (*) REQUIRED
  #
  # Name of the branch being used. Must match the one sent in the URL of the
  # request.
  branch: "main"

  commitMessage: "New comment by {fields.name}"

  # (*) REQUIRED
  #
  # Destination path (filename) for the data files. Accepts placeholders.
  filename: "comment-{@timestamp}"

  # The format of the generated data files. Accepted values are "json", "yaml"
  # or "frontmatter"
  format: "yaml"

  # List of fields to be populated automatically by Staticman and included in
  # the data file. Keys are the name of the field. The value can be an object
  # with a `type` property, which configures the generated field, or any value
  # to be used directly (e.g. a string, number or array)
  generatedFields:
    date:
      type: "date"
      options:
        format: "iso8601"

위의 부분은 특별히 수정할 것이 없다. _config.yml에서와 마찬가지로 branchmain인지 master인지를 구분해주면 충분하다.

  # Whether entries need to be approved before they are published to the main
  # branch. If set to `true`, a pull request will be created for your approval.
  # Otherwise, entries will be published to the main branch automatically.
  moderation: true

moderation의 값이 true라면 staticman은 pull request를 보낸 후 스스로 main 브랜치에 merge하지 않는다. False일 경우는 pull request를 보내고 자동으로 main 브랜치에 merge하여 거의 즉각적으로 댓글이 달리도록 해 준다.

  # Akismet spam detection.
  # akismet:
  #   enabled: true
  #   author: "name"
  #   authorEmail: "email"
  #   authorUrl: "url"
  #   content: "message"
  #   type: "comment"

스팸 관리를 할 수 있는 듯하다. 나는 사용하지 않아 주석처리된 그대로 두었다.

  # Name of the site. Used in notification emails.
  # name: "mademistakes.com"

  # Notification settings. When enabled, users can choose to receive notifications
  # via email when someone adds a reply or a new comment. This requires an account
  # with Mailgun, which you can get for free at http://mailgun.com.
  notifications:
    # Enable notifications
    enabled: false

    # (!) ENCRYPTED
    #
    # Mailgun API key
    # apiKey: "z49fOBsWoIHdVjIOEVWF/zx6wfUgsNdCoJkjB+9bzrd97Tis1OpE87k9vFmHEb7I9FfDMSW9KV+TbB6SSfT8l+EP9WKVH5/u/SxaBRz212a3QZzQVGqQB7PrfmYcgtsgOO0Wb59ApWwRGRHSZilMKXHg+wV2pqCno13RrrejRQU="

    # (!) ENCRYPTED
    #
    # Mailgun domain (encrypted)
    # domain: "WNnxjkTBQjlZvGmm95NXKL7iCy2ConWTaL1wkUoO4LJcOuWX0iKz0aDmKZdsl0MTH1TAjGLAEbCMIEzDkcaJFkbxNDwouRADfa57/jGWx+PTRhGs4C3nLEHMGNjxnOzjnQR/2x79SpVVmvosMy+g6EgqxlvVxKkqbjUaaWF4zSE="

위에서와 마찬가지로 Mailgun 서비스를 사용한다면 필요하다. 나는 사용하지 않으므로 주석처리 된 상태 그대로 두었다.

  # (*) REQUIRED
  #
  # Destination path (directory) for the data files. Accepts placeholders.
  path: "_data/comments/{options.slug}"

  # Names of required files. If any of these isn't in the request or is empty,
  # an error will be thrown.
  requiredFields: ["name", "message"]

path의 경우, staticman이 만든 댓글 파일이 저장될 위치를 나타낸다. requiredFields의 경우 작성 필수항목을 의미한다.

  # List of transformations to apply to any of the fields supplied. Keys are
  # the name of the field and values are possible transformation types.
  transforms:
    email: md5

아마도 댓글을 통해 이메일 등의 개인정보를 받을 시, staticman을 타고 보낼 때 보안을 위해 최소한의 암호화를 하는 것을 여기서 설정하는 것 같다. 나는 mailgun을 사용하지 않는 시점에서 이미 댓글 작성자의 이메일 주소를 받는 것은 무의미하다 생각하여 email 항목을 없앴고, 따라서 이 부분도 필요 없어서 건드리지 않았다.

  # reCaptcha
  # Register your domain at https://www.google.com/recaptcha/ and choose reCAPTCHA V2
  reCaptcha:
    enabled: true
    siteKey: "6LeiUpkgAAAAAN3VR6VD-g9b10-yf2r8DjRmfiVZ"
    # Encrypt reCaptcha secret key using Staticman /encrypt endpoint
    # For more information, https://staticman.net/docs/encryption
    secret: "AE8k5EQKTRnYyp/vpHoMUhAH/YVkzv36tfI+ZZZ2N5c/pFI7Afio0TQfD/FJdDUOpJ8UH+n5K1x7Yeqc5tbcfG20rpKpWCXTiehLJSqiMixuj12oPGzg7iD4Qecehc9o02vSk6pRS/lCAoxuO2GU9zTs8EyKYSqgCkLIKEzoZDnnVJKvpWoPhhpqmaogaYG0AvuLyoKwDoN1C8EwZlGg69btCtVgtcIWgCscVb4eOlc/TH+b3FbKAn2XfLlRmRaDhGpsAl9HMWoIFURnqQ4ZDkcD3S6H9tGNrJd1uUtsWuhnXz7iz5nvg2Z82aWqOD8hqAaCH/jLpKWFNJiFVGqjgzVmxn665k9NCGmPx7rFPUkmQN8MFjJi1WO+w1WmUTdf0N6+A253f9Ls5ZKqJHRsoXDubd9c9bTuFI1YmaTiXiU45vJXMQRTgqO2XgWSbIANigWC4fQXa6D42pFZMVlOs9zjQ2dww84hMfNjqA1djAGf7C4oWBMNZqzyDiPbWvwBFZ1MQqSBq1SWrrrxF1l501U9GrYaOpgFlKBm0d1l4BE5Wft176EOzkyGGvgzhKfWMDla8CrOAQD8Qglpz3chIMAVTXucCIjvK74yNuNloZsXcGqGaFQguNsa9EhiAhWwviHFQmVCcxOl2bbMuc1QN0qsQC1TO1J0cQs9kbfWd8c="

_config.yml에서 했던 것과 같이 reCaptcha의 siteKey와 secret을 입력하면 된다.

Comment 형식 수정

답글 기능을 추가하기 위해서는 _includes에 있는 comment.htmlcomments.html 파일을 수정해야 한다. 곳곳에서 찾은 파일들이 모두 말을 듣지 않아 이것저것 짜깁기하여 다음 코드를 만들었다. 링크 1, 링크 2, 링크 3

comment.html

<article id="comment{% unless include.r %}{{ index | prepend: '-' }}{% else %}{{ include.index | prepend: '-' }}{% endunless %}" class="js-comment comment {% if include.name == site.author.name %}admin{% endif %} {% unless include.replying_to == 0 %}child{% endunless %}">
   {% if include.replying_to != 0 %}
     <div class="comment__author" style="margin-left:3em">
       {{ include.name }},
       <span class="comment__date">
         {% if include.date %}
           {% if include.index %}
             <a href="#comment{{ include.index | prepend: '-' }}" title="Permalink to this comment">
           {% endif %}
           {{ include.date | date_to_long_string }}
           {% comment %}
             {% include format-date.html date=include.date time=false weekDay=false %}
           {% endcomment %}
           {% if include.index %}</a>{% endif %}
         {% endif %}
       </span>
     </div>

     <div class="comment__body" style="margin-left:3em">
       {{ include.message | markdownify }} 
     </div>

   {% else %}

     <div class="comment__author" style="margin-left:.5em">
       {{ include.name }},
       <span class="comment__date">
         {% if include.date %}
           {% if include.index %}
             <a href="#comment{{ include.index | prepend: '-' }}" title="Permalink to this comment">
           {% endif %}
           {{ include.date | date_to_long_string }}
           {% comment %}
             {% include format-date.html date=include.date time=false weekDay=false %}
           {% endcomment %}
           {% if include.index %}</a>{% endif %}
         {% endif %}
       </span>
     </div>

     <div class="comment__body" style="margin-left:.5em">
       {{ include.message | markdownify }} 
     </div>

     <div class="comment__reply" style="border:none; margin-bottom:2em; margin-left:1em;">
       <a rel="nofollow" href="#comment-{{ include.index }}" onclick="return addComment.moveForm('comment-{{ include.index }}', '{{ include.index }}', 'respond', '{{ page.slug }}')"><span class="material-icons md-14">&#xE5DA;</span>{{ include.name }}에게 답글 남기기</a>
     </div>

   {% endif %}  
 </article>

 {% capture i %}{{ include.index }}{% endcapture %}
 {% assign replies = site.data.comments[page.slug] | sort | where_exp: 'comment', 'comment[1].replying_to == i' %}
 {% for reply in replies %}
   {% assign index       = forloop.index | prepend: '-' | prepend: include.index %}
   {% assign replying_to = reply[1].replying_to | to_integer %}
   {% assign email       = reply[1].email %}
   {% assign name        = reply[1].name %}
   {% assign url         = reply[1].url %}
   {% assign date        = reply[1].date %}
   {% assign message     = reply[1].message %}
   {% include comment.html index=index replying_to=replying_to avatar=avatar email=email name=name url=url date=date message=message %}
 {% endfor %}

(이상 comment.html)

comments.html

<section id="static-comments">
 {% if site.repository and site.comments.staticman.branch %}

   {% if site.data.comments[page.slug] %}
     <!-- Existing comments -->
     <div class="comments-title">
     <h2>{{ site.data.ui-text[site.locale].comment_form_comment_label | default: "Comment" }}</h2>
     {% assign comments = site.data.comments[page.slug] | sort | where_exp: "comment", "comment[1].replying_to == ''" %}
         {% for comment in comments %}
           {% assign index       = forloop.index %}
           {% assign replying_to = comment[1].replying_to | to_integer %}
           {% assign name        = comment[1].name %}
           {% assign date        = comment[1].date %}
           {% assign message     = comment[1].message %}
           {% include comment.html index=index replying_to=replying_to name=name date=date message=message %}
         {% endfor %}
     </div>    
   {% endif %}

   <!-- Start new comment form -->
   <div class="page__comments-form" id="respond">
     <h4 class="page__comments-title">{{ site.data.ui-text[site.locale].comments_label | default: "Leave a Comment" }}</h4>
     <form id="new_comment" class="page__comments-form js-form form" method="post" action="{{ site.comments.staticman.endpoint }}{{ site.repository }}/{{ site.comments.staticman.branch }}/comments" autocomplete="off">
       <input type="hidden" name="options[origin]" value="{{ page.url | absolute_url }}">
       <input type="hidden" name="options[parent]" value="{{ page.url | absolute_url }}">
       <input type="hidden" id="comment-replying-to" name="fields[replying_to]" value="">
       <input type="hidden" name="options[slug]" value="{{ page.slug }}">
       <input type="hidden" name="options[reCaptcha][siteKey]" value="{{ site.reCaptcha.siteKey }}">
       <input type="hidden" name="options[reCaptcha][secret]"  value="{{ site.reCaptcha.secret }}">

       <div class="form-group">
         <label for="comment-form-name">{{ site.data.ui-text[site.locale].comment_form_name_label | default: "Name" }}</label>
         <input type="text" id="comment-form-name" name="fields[name]" tabindex="2" />
       </div>

       <div class="form-group">
         <label for="comment-form-message">{{ site.data.ui-text[site.locale].comment_form_comment_label | default: "Comment" }} <small><a rel="nofollow" id="cancel-comment-reply-link" href="{{ page.url | absolute_url }}#respond" style="display:none;">답글 취소</a></small></label>
         <textarea type="text" rows="3" id="comment-form-message" name="fields[message]" tabindex="1"></textarea>
         <div class="small help-block"><a href="https://daringfireball.net/projects/markdown/">{{ site.data.ui-text[site.locale].comment_form_md_info | default: "Markdown is supported." }}</a></div>
       </div>

       <div class="form-group">
         <div class="g-recaptcha" data-sitekey="{{ site.reCaptcha.siteKey }}"></div>
       </div>

       <!-- Start comment form alert messaging -->
       <p class="hidden js-notice">
         <strong class="js-notice-text"></strong>
       </p>
       <!-- End comment form alert messaging -->   

       <div class="form-group">
         <button type="submit" id="comment-form-submit" tabindex="5" class="btn btn--primary btn--large">{{ site.data.ui-text[site.locale].comment_btn_submit | default: "Submit Comment" }}</button>
       </div>
     </form>
   </div>
   <!-- End new comment form -->

   {% if site.reCaptcha.siteKey %}<script async src="https://www.google.com/recaptcha/api.js"></script>{% endif %}
 {% endif %}
 </section>

(이상 comments.html)

그리고 답글기능이 동작하게 하기 위해 다음 javascript를 저장해둔 후, _config.yml에서 불러온다.

// Static comments
 // from: https://github.com/eduardoboucas/popcorn/blob/gh-pages/js/main.js 
 (function ($) {
   var $comments = $('.js-comments');

   $('.js-form').submit(function () {
     var form = this;

 //Spinner from Travis Downs and MadeMistakes
     $(form).addClass('disabled');
     $('#comment-form-submit').html('<i class="fas fa-spinner fa-spin fa-fw"></i>  Submitting');
 //
     $.ajax({
       type: $(this).attr('method'),
       url:  $(this).attr('action'),
       data: $(this).serialize(),
       contentType: 'application/x-www-form-urlencoded',
       success: function (data) {
         showModal('Comment submitted', 'Thanks! Your comment is <a href="https://github.com/willymcallister/willymcallister.github.io/pulls">pending</a>. It will appear when approved.');
         //Spinner
         $("#comment-form-submit")
           .html("Submit");

         //$(form)[0].reset();   // clear contents of form after submit (commented out by WMc)
         $(form).removeClass('disabled');
         grecaptcha.reset();
         //
       },
       error: function (err) {
         console.log(err);
         //Spinner
         var ecode = (err.responseJSON || {}).errorCode || "unknown";
         showModal('Error', 'An error occured.<br>[' + ecode + ']');
         $('#comment-form-submit').html('Submit')
         $(form).removeClass('disabled');
         grecaptcha.reset();
         //
       }
     });
     return false;
   });

   $('.js-close-modal').click(function () {
     $('body').removeClass('show-modal');
   });

   function showModal(title, message) {
     $('.js-modal-title').text(title);
     $('.js-modal-text').html(message);
     $('body').addClass('show-modal');
   }
 })(jQuery);

 // Staticman comment replies, from https://github.com/mmistakes/made-mistakes-jekyll
 // modified from Wordpress https://core.svn.wordpress.org/trunk/wp-includes/js/comment-reply.js
 // Released under the GNU General Public License - https://wordpress.org/about/gpl/
 // addComment.moveForm is called from comment.html when the reply link is clicked.
 var addComment = {
   moveForm: function( commId, parentId, respondId, postId ) {
     var div, element, style, cssHidden,
     t           = this,                    //t is the addComment object, with functions moveForm and I, and variable respondId
     comm        = t.I( commId ),                                //whole comment
     respond     = t.I( respondId ),                             //whole new comment form
     cancel      = t.I( 'cancel-comment-reply-link' ),           //whole reply cancel link
     parent      = t.I( 'comment-replying-to' ),                 //a hidden element in the comment
     post        = t.I( 'comment-post-slug' ),                   //null
     commentForm = respond.getElementsByTagName( 'form' )[0];    //the <form> part of the comment_form div

     if ( ! comm || ! respond || ! cancel || ! parent || ! commentForm ) {
       return;
     }

     t.respondId = respondId;
     postId = postId || false;

     if ( ! t.I( 'sm-temp-form-div' ) ) {
       div = document.createElement( 'div' );
       div.id = 'sm-temp-form-div';
       div.style.display = 'none';
       respond.parentNode.insertBefore( div, respond ); //create and insert a bookmark div right before comment form
     }

     comm.parentNode.insertBefore( respond, comm.nextSibling );  //move the form from the bottom to above the next sibling
     if ( post && postId ) {
       post.value = postId;
     }
     parent.value = parentId;
     cancel.style.display = '';                        //make the cancel link visible

     cancel.onclick = function() {
       var t       = addComment,
       temp    = t.I( 'sm-temp-form-div' ),            //temp is the original bookmark
       respond = t.I( t.respondId );                   //respond is the comment form

       if ( ! temp || ! respond ) {
         return;
       }

       t.I( 'comment-replying-to' ).value = null;      //forget the name of the comment
       temp.parentNode.insertBefore( respond, temp );  //move the comment form to its original location
       temp.parentNode.removeChild( temp );            //remove the bookmark div
       this.style.display = 'none';                    //make the cancel link invisible
       this.onclick = null;                            //retire the onclick handler
       return false;
     };

     /*
      * Set initial focus to the first form focusable element.
      * Try/catch used just to avoid errors in IE 7- which return visibility
      * 'inherit' when the visibility value is inherited from an ancestor.
      */
     try {
       for ( var i = 0; i < commentForm.elements.length; i++ ) {
         element = commentForm.elements[i];
         cssHidden = false;

         // Modern browsers.
         if ( 'getComputedStyle' in window ) {
           style = window.getComputedStyle( element );
         // IE 8.
         } else if ( document.documentElement.currentStyle ) {
         style = element.currentStyle;
         }

       /*
        * For display none, do the same thing jQuery does. For visibility,
        * check the element computed style since browsers are already doing
        * the job for us. In fact, the visibility computed style is the actual
        * computed value and already takes into account the element ancestors.
        */
         if ( ( element.offsetWidth <= 0 && element.offsetHeight <= 0 ) || style.visibility === 'hidden' ) {
           cssHidden = true;
         }

         // Skip form elements that are hidden or disabled.
         if ( 'hidden' === element.type || element.disabled || cssHidden ) {
           continue;
         }

         element.focus();
         // Stop after the first focusable element.
         break;
       }

     } catch( er ) {}

     return false;
   },

   I: function( id ) {
     return document.getElementById( id );
   }
 };

여기까지 따라했을 경우 답글기능까지 동작해야 한다. 커밋의 나머지 부분들은 디자인에 관련된 부분들이다.

댓글남기기