טוקבקים באתר סטטי באמצעות Staticman
מערכות התגובות של Disqus ופייסבוק בעייתיות. במקומן, נטמיע באתר את Staticman.
תגובות (טוקבקים) הן חלק משמעותי מחוויית הגלישה באתרים רבים, והן מאפשרות למשתמשים להגיב ולשוחח על הפוסטים באתר. פלטפורמות כמו וורדפרס כבר כוללות בתוכן מערכת תגובות, שבעלי אתרים יכולים בקלות להפעיל באתריהם. אולם, באתרים סטטיים כמו האתר הזה, הוספת תגובות היא עניין יותר מורכב.
כאן מתואר האופן שבו התמודדתי עם המורכבות הזו, על ידי הטמעת מערכת תגובות בשם סטטיקמן (Staticman) באתר.
תוכן עניינים
הבעיה עם תגובות באתרים סטטיים
אתרים סטטיים מורכבים אך ורק מקבצים כמו HTML, CSS, JavaScript, וכו׳ שיושבים להם על שרת כלשהו, ללא שום מסד נתונים, קוד PHP, או איזשהו backend שמעבד נתונים ברקע. לאתרים סטטיים יש יתרונות מבחינת עלות, מהירות ובטיחות, בעיקר עבור אתרים קטנים כמו זה, אך מבנה כזה מקשה על הוספת מרכיבים דינמיים, כמו מערכת תגובות, לאתר.
אחד הפתרונות הוא להשתמש במערכת תגובות חיצונית. בעבר השתמשתי ב-Disqus, שאפשר להטמיע בכל אתר בעזרת קוד JavaScript פשוט. עם זאת, יש ל-Disqus מספר בעיות משמעותיות. למשל, הוא קורא לכל מיני סקריפטים חיצוניים שמאטים את זמן הטעינה של האתר. אך הבעיה העיקרית מבחינתי היא הפגיעה בפרטיות המשתמשים. ל-Disqus יש היסטוריה מפוקפקת בכל הקשור לפרטיות, ולפני מספר שנים הם נקנו על ידי חברת ביג דאטה, שמציעה למכירה את המידע שנאסף על משתמשי Disqus.
חלופה ל-Disqus הינה פלאגין התגובות של פייסבוק, אבל לתת לצוקרברג להריץ סקריפטים באתר נשמע כמו רעיון אפילו יותר גרוע מבחינת פרטיות.
חלופות אחרות הן utterances ו-giscus שמתבססות על מערכת ה-issues וה-discussions של גיטהאב, בהתאמה, בתור מערכת תגובות. החיסרון העיקרי שלהן הוא שהן דורשות חשבון גיטהאב בשביל להגיב ושהן דורשות הרשאות יחסית גבוהות מחשבון הגיטהאב.
חלופה טובה שדומה ל-Disqus היא Commento, שלה קוד פתוח ושאותה אפשר אפילו להריץ ב-Heroku. אולם, ל-Commento אין תמיכה בעברית, ולהריץ ב-Heroku נשמע כמו תסבוכת מיותרת.
חיפשתי מערכת תגובות פשוטה וזולה, שיהיה אפשר להטמיע בקלות באתר, ושתהיה לה תמיכה בעברית (מה שאין לשלוש החלופות האחרונות שהצגתי). לאחר חיפוש רב והתנסות עם כל מיני מערכות תגובות שונות, בסוף מצאתי אחת שמתאימה לי בול.
הפתרון: Staticman
מערכת התגובות שהכי מתאימה לצרכיי היא סטטיקמן. סטטיקמן משמש כ-API שאליו נשלחות התגובות, ועבור כל תגובה הוא יוצר pull request ב-repository של האתר בגיטהאב. האתר מאוחסן בנטליפיי, שאוטומטית מסנכרנים את האתר עם כל שינוי בגיטהאב. כך, ברגע שממזגים pull request של תגובה, התגובה ישר מופיעה באתר עצמו.
לנטליפיי יש שירות נחמד של serverless functions, שמתבסס על AWS Lambda למי שמכיר. את סטטיקמן אפשר להריץ בקלות ובחינם לצד האתר כפונקציה כזו, שרצה לשניות בודדות כל פעם שנשלחת תגובה באתר. גם חברות אחרות, כגון Cloudflare ו-Vercel, מציעות שירותים דומים של serverless functions, ואפשר גם להריץ את סטטיקמן ב-Heroku. מכיוון שהאתר שלי כבר מאוחסן אצל נטליפיי, הכי נוח עבורי להשתמש בשירות הפונקציות שלהם.
הטמעה באתר
את האתר הזה בניתי בעזרת הוגו עם תבנית בשם Wowchemy. על שניהם כבר הסברתי בפוסט קודם. הקוד שאראה בחלק הזה מותאם ל-Wowchemy, אבל אפשר בקלות להתאים אותו לתבניות אחרות, ועם מעט עבודה גם לכלים אחרים כמו ג׳קיל וגטסבי.
יש ליצור קובץ layouts/partials/comments/staticman.html
ולשים בו את הקוד הבא:
<script>
function replyTo(parent, name) {
var e = document.getElementById('commentid-' + parent),
f = document.getElementById('comment-form'),
h = document.getElementById('comment-form-header');
h.innerHTML = 'הגב ל-' + name + ' <a class="cancel-reply" href="#comment-form" onclick="cancelReply()">ביטול</a>';
e.parentNode.insertBefore(f, e.nextSibling);
document.getElementsByName('fields[reply_to]')[0].value=parent;
window.location.href = '#comment-form'
};
function cancelReply() {
var f = document.getElementById('comment-form'),
h = document.getElementById('comment-form-header');
h.innerHTML = 'השארת תגובה';
f.parentNode.insertBefore(f, f.parentNode.lastChild.nextSibling);
document.getElementsByName('fields[reply_to]')[0].value="";
};
</script>
{{ $postSlug := .File.ContentBaseName }}
{{ $commentsNum := 0 }}
{{ if isset $.Site.Data.comments $postSlug }}
{{ .Scratch.Set "comments" (index $.Site.Data.comments $postSlug) }}
{{ $commentsNum = len (.Scratch.Get "comments") }}
{{ end }}
<h3 class="comments-title"><a href="#comments"><i class="fas fa-comments"></i> {{ $commentsNum }} תגובות</a></h3>
<section class="staticman-comments post-comments">
{{ if ne $commentsNum 0 }}
{{ range (.Scratch.Get "comments") }}
{{ if not .reply_to }}
<div id="commentid-{{ ._id }}" class="card-simple post-comment">
<div class="post-comment-header">
<img class="post-comment-avatar" src="https://www.gravatar.com/avatar/{{ .email }}?s=50&r=pg&d=identicon" alt="{{ .name }}" loading="lazy" width="50" height="50">
<p class="post-comment-info">
<span class="post-comment-name">{{ .name }}</span>
<br>
<a href="#commentid-{{ ._id }}" title="קישור לתגובה">
<span class="post-time">{{ dateFormat "02/01/2006" .date }}</span>
</a>
</p>
</div>
<div class="comment-message">
{{ .comment | htmlEscape | replaceRE "\n" "<br>" | safeHTML }}
</div>
<div class="comment__reply">
<button id="{{ ._id }}" class="btn btn-primary" onclick="replyTo('{{ ._id }}', '{{ .name }}')">הגב ל-{{ .name }}</button>
</div>
{{ partial "partials/comments/comment-replies" (dict "entryId_parent" $postSlug "SiteDataComments_parent" $.Site.Data.comments "parentId" ._id "parentName" .name "context" .) }}
</div>
{{ end }}
{{ end }}
{{ end }}
<form id="comment-form" class="post-new-comment" method="post" action="/.netlify/functions/staticman?branch=master">
{{ if eq $commentsNum 0 }}
<p>היו הראשונים להשאיר תגובה!</p>
{{ end }}
<h3 class="prompt" id="comment-form-header">השארת תגובה</h3>
<input type="hidden" name="options[redirect]" value="{{ .Permalink }}#comment-submitted">
<input type="hidden" name="options[redirectError]" value="{{ .Permalink }}#comment-error">
<input type="hidden" name="options[slug]" value="{{ $postSlug }}">
<input type="hidden" name="options[parent]" value="{{ $postSlug }}">
<input type="hidden" id="comment-parent" name="fields[reply_to]" value="">
<div class="form-group form-inline">
<input type="text" name="fields[name]" class="form-control w-100" placeholder="שם" required/>
</div>
<div class="form-group form-inline">
<input type="email" name="fields[email]" class="form-control w-100" placeholder="דוא״ל" required/>
</div>
<div class="form-group">
<textarea name="fields[comment]" class="form-control" placeholder="כתיבת תגובה" required rows="5"></textarea>
</div>
<input type="submit" class="btn btn-primary px-3 py-2 w-100" value="שליחה">
</form>
</section>
<div id="comment-submitted" class="dialog">
<h3>תודה!</h3>
<p>התגובה נשלחה ותפורסם ברגע שתאושר.</p>
<p><a href="#" class="btn btn-primary">אוקיי</a></p>
</div>
<div id="comment-error" class="dialog">
<h3>אופס!</h3>
<p>שליחת התגובה נכשלה. אנא נסו שוב.</p>
<p><a href="#" class="btn btn-primary">אוקיי</a></p>
</div>
יש ליצור קובץ layouts/partials/comments/comment-replies.html
ולשים בו את הקוד הבא:
{{ range $index, $comments := (index $.SiteDataComments_parent $.entryId_parent ) }}
{{ if eq .reply_to $.parentId }}
<div id="commentid-{{ ._id }}" class="card-simple post-comment reply">
<div class="post-comment-header">
<img class="post-comment-avatar" src="https://www.gravatar.com/avatar/{{ .email }}?s=50&r=pg&d=identicon" alt="{{ .name }}" loading="lazy" width="50" height="50">
<p class="post-comment-info">
<span class="post-comment-name">{{ .name }}<br><i><small>בתגובה ל-{{ $.parentName }}</i></small></span>
<br>
<a href="#commentid-{{ ._id }}" title="קישור לתגובה">
<span class="post-time">{{ dateFormat "02/01/2006" .date }}</span>
</a>
</p>
</div>
<div class="comment-message">
{{ .comment | htmlEscape | replaceRE "\n" "<br>" | safeHTML }}
</div>
<div class="comment__reply">
<button id="{{ ._id }}" class="btn btn-primary" onclick="replyTo('{{ $.parentId }}', '{{ $.parentName }}')">הגב לשרשור</button>
</div>
</div>
{{ end }}
{{ end }}
שימו לב שתמונות הפרופיל לתגובות יילקחו מה-API של גראווטר לפי כתובות הדוא״ל.
ב-assets/scss/custom.scss
ניתן להגדיר את העיצוב לתגובות, ומומלץ להתאים אישית בהתאם לאתר שלכם. באתר הזה הגדרתי כך:
#comments {
overflow-wrap: break-word;
word-wrap: break-word;
word-break: break-word;
}
.post-comment-header {
margin-bottom: 20px;
}
.post-comment-avatar {
display: inline-block;
vertical-align: middle;
border-radius: 50%;
}
.post-comment-info {
display: inline-block;
margin-left: 20px;
margin-bottom: 0;
vertical-align: middle;
}
.post-comment-info .post-comment-name {
font-size: 1rem;
font-weight: 500;
}
.post-comment-info .post-time {
font-size: 0.7rem;
font-weight: normal;
letter-spacing: 0.03em;
color: rgba(0, 0, 0, 0.54);
}
.comment-message {
font-size: 0.85rem;
margin-bottom: 1rem;
}
.comments-title a {
color: unset;
}
.cancel-reply {
font-size: 0.9rem;
}
.reply {
background: $sta-background;
}
.dialog {
display: none;
position: fixed;
background-color: rgb(247, 247, 247);
padding: 25px;
padding-top: 20%;
top: 0;
left: 0;
width: 100%;
height: 100%;
text-align: center;
}
.dialog:target {
display: block;
}
כל מה שנותר בשביל להטמיע באתר זה להגדיר בקובץ config/_default/params.yaml
, תחת comments:
את provider: staticman
.
התקנת ה-API של סטטיקמן
הוספת הקוד
כעת, אנו צריכים להתקין את ה-API של סטטיקמן, שיגשר בין טופס התגובות באתר לבין ה-repository בגיטהאב. החלטתי להתקין אותו כ-serverless function אצל נטליפיי, שמאחסנים גם את האתר עצמו. אני מאמין שאפשר בקלות להתאים את ההוראות שכאן גם לשירותי serverless functions של חברות אחרות, וניתן גם להתקין ב-Heroku לפי ההוראות שכאן.
תחילה, בקובץ package.json
הוסיפו את @staticman/netlify-functions
כ-dependency. אם אין לכם קובץ package.json
, צרו אחד ושימו בו:
{
"dependencies": {
"@staticman/netlify-functions": "^1.7.0"
},
"name": "my-site",
"version": "0.1.0"
}
לאחר מכן, צרו קובץ functions/staticman.js
עם הקוד הבא:
const { processEntry } = require("@staticman/netlify-functions");
const queryString = require("querystring");
exports.handler = (event, context, callback) => {
const repo = process.env.REPO;
const [username, repository] = repo.split("/");
const bodyData = queryString.parse(event.body);
event.queryStringParameters = {
...event.queryStringParameters,
...bodyData,
username,
repository,
};
const config = {
origin: event.headers.origin,
sites: {
[repo]: {
allowedFields: ["name", "email", "comment", "reply_to"],
branch: "master",
commitMessage: "Add comment by {fields.name} in {options.slug}",
filename: "comment-{@timestamp}",
format: "yml",
generatedFields: {
date: {
type: "date",
options: {
format: "iso8601",
},
},
},
moderation: true,
path: "data/comments/{options.slug}",
requiredFields: ["name", "email", "comment"],
transforms: {
email: "md5"
},
},
},
};
return processEntry(event, context, callback, config);
};
יצירת חשבון גיטהאב לסטטיקמן
בשביל שסטטיקמן יוכל ליצור pull requests בגיטהאב עם התגובות, אנחנו צריכים ליצור חשבון גיטהאב עבורו.
לאחר מכן, ב-repository של האתר בגיטהאב, לכו ל-Settings > Manage access והזמינו את החשבון החדש שיצרתם כ-collaborator.
תקבלו במייל הזמנה שתצטרכו לאשר:
בשביל שלסטטיקמן תהיה גישה לחשבון הגיטהאב שיצרנו עבורו, יש להיכנס לכאן וליצור personal access token. את הטוקן יש לשמור, ובקרוב נשתמש בו.
הגדרה בנטליפיי
כנסו להגדרות האתר בממשק של נטליפיי. ב-Site settings > Functions רדו ל-Functions directory והגדירו אותו בתור functions
.
לכו ל-Build & deploy ורדו ל-Environment variables. צרו משתנה חדש בשם GITHUB_TOKEN
ושימו בו את ה-personal access token שקיבלתם מוקדם יותר. צרו גם משתנה בשם REPO
ושימו בו את שם ה-repository שלכם בגיטהאב בפורמט של username/repo-name
(למשל, paazca/my-site
).
בצעו deploy מחדש לאתר שלכם בשביל שיסתנכרן עם המשתנים שהגדרתם.
כעת, מערכת התגובות אמורה כבר לעבוד. נסו לשלוח תגובה באתר, ותראו שהיא תופיע באתר כחצי דקה לאחר מיזוג ה-pull request שסטטיקמן יצר.
בחלק הבא אסביר איך לחסום תגובות ספאם בעזרת Akismet.
חסימת ספאם בעזרת Akismet
על מנת לחסום תגובות ספאם באתר, נשתמש בשירות Akismet של Automattic.
צרו חשבון, ושמרו את ה-API key שתקבלו.
חזרו להגדרת ה-Environment variables בנטליפיי (תחת Site settings > Build & deploy). צרו משתנה חדש בשם AKISMET_API_KEY
ושימו בו את ה-API key שקיבלתם מ-Akismet. צרו משתנה חדש בשם AKISMET_SITE
ושימו בו את כתובת האתר שלכם.
נחזור עכשיו לקובץ functions/staticman.js
. בתוך אובייקט הקונפיגורציה, תחת [repo]: {
, שימו:
akismet: {
enabled: true,
author: "name",
authorEmail: "email",
content: "comment",
type: "comment",
},
נסו לשלוח תגובה באתר, והאתר שלכם אמור יהיה להופיע בחשבון ה-Akismet שלכם, תחת Recently active sites.
אתם מוזמנים לנסות את מערכת התגובות ולהגיב כאן למטה, ולאחר אישור, תגובתכם תופיע תחת הפוסט.
מחבר פז כהן-אברמוביץ׳
17/09/2021
מחבר פז כהן-אברמוביץ׳
בתגובה ל-פז כהן-אברמוביץ׳
17/09/2021