טוקבקים באתר סטטי באמצעות 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

סטטיק (לירז רוסו)
לא הסטטיק הזה (צילום: ישראל בידור, CC BY 3.0)

מערכת התגובות שהכי מתאימה לצרכיי היא סטטיקמן. סטטיקמן משמש כ-API שאליו נשלחות התגובות, ועבור כל תגובה הוא יוצר pull request ב-repository של האתר בגיטהאב. האתר מאוחסן בנטליפיי, שאוטומטית מסנכרנים את האתר עם כל שינוי בגיטהאב. כך, ברגע שממזגים pull request של תגובה, התגובה ישר מופיעה באתר עצמו.

graph TD A(fas:fa-comment משתמש שולח תגובה) --> B(fas:fa-bolt סטטיקמן מקבל את התגובה) B --> C(fab:fa-github סטטיקמן יוצר pull request בגיטהאב) C --> D(fas:fa-user-shield מנהל האתר ממזג את ה-pull request) D --> E(fas:fa-cloud נטליפיי בונים מחדש את האתר עם התגובה החדשה)

Pull request של סטטיקמן
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.

הזמנה כ-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 שתקבלו.

מפתח ה-Akismet

חזרו להגדרת ה-Environment variables בנטליפיי (תחת Site settings > Build & deploy). צרו משתנה חדש בשם AKISMET_API_KEY ושימו בו את ה-API key שקיבלתם מ-Akismet. צרו משתנה חדש בשם AKISMET_SITE ושימו בו את כתובת האתר שלכם.

הגדרת מפתח ה-Akismet כמשתנה סביבה

נחזור עכשיו לקובץ functions/staticman.js. בתוך אובייקט הקונפיגורציה, תחת [repo]: {, שימו:

akismet: {
  enabled: true,
  author: "name",
  authorEmail: "email",
  content: "comment",
  type: "comment",
},

נסו לשלוח תגובה באתר, והאתר שלכם אמור יהיה להופיע בחשבון ה-Akismet שלכם, תחת Recently active sites.

אתרים פעילים לאחרונה ב-Akismet

אתם מוזמנים לנסות את מערכת התגובות ולהגיב כאן למטה, ולאחר אישור, תגובתכם תופיע תחת הפוסט.

מקורות ולקריאה נוספת

פז כהן-אברמוביץ׳
פז כהן-אברמוביץ׳
חובב טכנולוגיה, סטודנט למדעי המחשב באוניברסיטה הפתוחה

2 תגובות

פז כהן-אברמוביץ׳

מחבר פז כהן-אברמוביץ׳
17/09/2021

היי!
זו תגובה לדוגמה.
פז כהן-אברמוביץ׳

מחבר פז כהן-אברמוביץ׳
בתגובה ל-פז כהן-אברמוביץ׳

17/09/2021

וזו תגובה לתגובה.

השארת תגובה

תודה!

התגובה נשלחה ותפורסם ברגע שתאושר.

אוקיי

אופס!

שליחת התגובה נכשלה. אנא נסו שוב.

אוקיי

הבא
הקודם

קשור