댓글 기능 추가 (2)
내용이 길어져 어쩔 수 없이 두 개의 글로 나누게 되었다. 앞선 글에서는 모두 사이트 저장소 바깥에서 해야 할 일들을 했고, 이제 사이트 저장소를 수정해주면 된다.
_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
에서와 마찬가지로 branch
만 main
인지 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.html
과 comments.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"></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 );
}
};
여기까지 따라했을 경우 답글기능까지 동작해야 한다. 커밋의 나머지 부분들은 디자인에 관련된 부분들이다.
댓글남기기