Lewati ke konten utama
  1. Posts/

Cara Membuat Safe Link di Blog Hugo dengan Timer & i18n Support

·2286 kata·11 menit·
Noor Khafidzin
Penulis
Noor Khafidzin
Seorang homelab enthusiast yang terobsesi pada efisiensi sistem dan seni pemecahan masalah (troubleshooting).
Daftar isi

Kalau kamu seorang blogger Hugo yang sering berbagi link afiliasi, referral, atau link download eksternal — kamu pasti tahu masalahnya.

Link langsung itu rentan. Mudah di-skip, mudah di-bypass, dan tidak memberikanmu kontrol apapun atas siapa yang mengaksesnya.

Safelink System hadir untuk menyelesaikan masalah itu.

Dengan Hugo Safelink System, setiap link eksternal yang kamu bagikan akan:

  • Dienkripsi menggunakan Base64 encoding sehingga URL asli tidak terlihat langsung
  • Mengarahkan pengunjung ke salah satu artikel random di blogmu
  • Memaksa pengunjung membaca selama 20 detik (timer countdown) sebelum bisa mengakses link tujuan
  • Menampilkan tombol akses link hanya setelah timer selesai

Hasilnya? Pageview naik, bounce rate turun, dan link eksternalmu tetap terlindungi.


Hugo Safelink System adalah sistem 3 komponen yang saling terintegrasi, dibangun 100% di atas Hugo template engine — tanpa plugin, tanpa backend, tanpa database.

Tiga komponen utamanya:

Komponen URL Fungsi
Safe Link Generator /safelink/ Mengubah URL asli menjadi safe link terenkripsi
Redirect Handler /go/?hash=... Mendecode hash dan redirect ke artikel random
Timer & Button Di setiap artikel Menampilkan countdown dan tombol akses link

Cara Kerja Sistem (Flow Lengkap)
#

Berikut alur lengkap dari klik pertama hingga akses link tujuan:

1. Blogger generate safe link di /safelink/
   Input: https://x.com
   Output: https://domain.com/go/?hash=aHR0cHM6Ly94LmNvbQ==

2. Pengunjung klik safe link tersebut

3. /go/ handler aktif:
   → Decode Base64 hash → dapat URL asli
   → Simpan URL di sessionStorage
   → Pilih artikel random dari /posts/
   → Redirect ke: /posts/random-article/?safelink=true

4. Di halaman artikel:
   → Timer countdown 20 detik muncul
   → Progress bar berjalan
   → Setelah selesai: tombol "Buka Link Eksternal" muncul

5. Pengunjung klik tombol → URL asli terbuka di tab baru

Fitur Lengkap
#

  • Enkripsi URL — Base64 encoding untuk menyembunyikan URL asli
  • Random Post Redirect — Setiap klik mengarah ke artikel berbeda, meningkatkan pageview
  • 20-Second Countdown Timer — Configurable, bisa diubah sesuai kebutuhan
  • Progress Bar — Visual indicator sisa waktu timer
  • Responsive Design — Optimal di desktop, tablet, dan mobile
  • Dark Mode Support — Mengikuti tema light/dark website
  • SessionStorage + LocalStorage Fallback — URL tersimpan aman meski session berakhir
  • i18n / Multilingual Support — Teks UI menyesuaikan bahasa aktif secara otomatis
  • Security Attributes — Link dibuka dengan rel="noopener noreferrer"

Prerequisite Sebelum Instalasi
#

Pastikan environment kamu sudah memenuhi syarat berikut:

  • Hugo versi 0.87 atau lebih baru (dengan dukungan i18n)
  • Folder /i18n/ sudah ada di root project
  • Minimal 1 artikel aktif (bukan draft) di /content/posts/

Cek versi Hugo kamu:

hugo version

Instalasi Step-by-Step
#

Step 1: Setup File i18n (Terjemahan UI)
#

Buat dua file terjemahan di folder /i18n/:

i18n/en.yaml (English)

- id: "safelink_timer_started"
  translation: "⏱️ Timer started..."
- id: "safelink_timer_completed"
  translation: "✓ Timer Completed!"
- id: "safelink_seconds"
  translation: "seconds"
- id: "safelink_wait_message"
  translation: "Wait for the timer to finish to access external link"
- id: "safelink_scroll_hint"
  translation: "⬇️ Scroll to continue"
- id: "safelink_access_link"
  translation: "✓ Access External Link"
- id: "safelink_open_external_link"
  translation: "🔗 Open External Link"
- id: "safelink_link_warning"
  translation: "⚠️ Link will open in new tab"

i18n/id.yaml (Indonesian)

- id: "safelink_timer_started"
  translation: "⏱️ Timer dimulai..."
- id: "safelink_timer_completed"
  translation: "✓ Timer Selesai!"
- id: "safelink_seconds"
  translation: "detik"
- id: "safelink_wait_message"
  translation: "Tunggu timer selesai untuk mengakses link eksternal"
- id: "safelink_scroll_hint"
  translation: "⬇️ Gulir untuk melanjutkan"
- id: "safelink_access_link"
  translation: "✓ Akses Link Eksternal"
- id: "safelink_open_external_link"
  translation: "🔗 Buka Link Eksternal"
- id: "safelink_link_warning"
  translation: "⚠️ Link akan membuka di tab baru"

💡 Tip: Semua teks UI dikontrol dari sini. Tidak perlu menyentuh kode template untuk mengubah teks tampilan.


Buat file layouts/partials/safelink-timer.html:

<div id="safelink-timer-container" 
     class="mt-8 p-6 bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-700 rounded-lg hidden mb-8"
     data-timer-started="{{ i18n "safelink_timer_started" }}"
     data-timer-completed="{{ i18n "safelink_timer_completed" }}"
     data-seconds="{{ i18n "safelink_seconds" }}"
     data-wait-message="{{ i18n "safelink_wait_message" }}"
     data-scroll-hint="{{ i18n "safelink_scroll_hint" }}"
     data-access-link="{{ i18n "safelink_access_link" }}"
     data-open-external-link="{{ i18n "safelink_open_external_link" }}"
     data-link-warning="{{ i18n "safelink_link_warning" }}">
  <div class="text-center">
    <h3 class="text-lg font-bold text-yellow-900 dark:text-yellow-100 mb-4" id="safelink-title">
      {{ i18n "safelink_timer_started" }}
    </h3>
    <div class="text-4xl font-bold text-yellow-600 dark:text-yellow-400 mb-4">
      <span id="safelink-timer">20</span>
      <span class="text-lg" id="safelink-seconds-label">{{ i18n "safelink_seconds" }}</span>
    </div>
    <p class="text-sm text-yellow-800 dark:text-yellow-200 mb-4" id="safelink-wait-message">
      {{ i18n "safelink_wait_message" }}
    </p>
    <div class="w-full bg-yellow-200 dark:bg-yellow-800 rounded-full h-2 overflow-hidden">
      <div id="safelink-progress" class="bg-yellow-600 dark:bg-yellow-400 h-full transition-all duration-300" style="width: 100%"></div>
    </div>
    <p id="safelink-scroll-hint" class="text-xs text-yellow-700 dark:text-yellow-300 mt-3 hidden">
      {{ i18n "safelink_scroll_hint" }}
    </p>
  </div>
</div>

<script>
document.addEventListener('DOMContentLoaded', () => {
  const urlParams = new URLSearchParams(window.location.search);
  const isSafelink = urlParams.get('safelink') === 'true' || urlParams.get('safelink') === '1';

  if (!isSafelink) return;

  const timerContainer = document.getElementById('safelink-timer-container');

  function t(key) {
    const attrKey = 'data-' + key.replace(/([A-Z])/g, '-$1').toLowerCase();
    return timerContainer.getAttribute(attrKey) || key;
  }

  const timerElement = document.getElementById('safelink-timer');
  const progressElement = document.getElementById('safelink-progress');
  const scrollHint = document.getElementById('safelink-scroll-hint');

  timerContainer.classList.remove('hidden');

  let originalUrl = sessionStorage.getItem('safelink_original_url') || localStorage.getItem('safelink_original_url');
  let timerSeconds = parseInt(sessionStorage.getItem('safelink_timer_seconds') || '20');

  if (!originalUrl || !originalUrl.match(/^https?:\/\//)) {
    timerContainer.style.display = 'none';
    return;
  }

  let remainingTime = timerSeconds;

  const interval = setInterval(() => {
    remainingTime--;
    timerElement.textContent = remainingTime;

    const percentage = (remainingTime / timerSeconds) * 100;
    progressElement.style.width = percentage + '%';

    if (remainingTime <= 0) {
      clearInterval(interval);

      timerContainer.classList.add('border-green-400', 'dark:border-green-700', 'bg-green-50', 'dark:bg-green-900/20');
      timerContainer.classList.remove('border-yellow-400', 'dark:border-yellow-700', 'bg-yellow-50', 'dark:bg-yellow-900/20');

      timerContainer.querySelector('h3').textContent = t('timerCompleted');
      timerContainer.querySelector('.text-4xl').style.display = 'none';
      timerContainer.querySelector('.text-sm').style.display = 'none';
      timerContainer.querySelector('.h-2').style.display = 'none';

      scrollHint.classList.remove('hidden');
      addButtonToArticle(originalUrl);
      localStorage.setItem('safelink_original_url', originalUrl);
    }
  }, 1000);

  function addButtonToArticle(url) {
    const articleContent = document.querySelector('.article-content');
    if (!articleContent) return;

    const buttonDiv = document.createElement('div');
    buttonDiv.className = 'text-center mt-8 p-6 bg-green-50 dark:bg-green-900/20 border-2 border-green-400 dark:border-green-700 rounded-lg';
    buttonDiv.innerHTML = `
      <h3 class="text-lg text-center font-bold text-green-900 dark:text-green-100 mb-4">
        ${t('accessLink')}
      </h3>
      <a href="${url}" target="_blank" rel="noopener noreferrer"
         class="inline-block !rounded-md bg-primary-600 px-4 py-2 !text-neutral !no-underline hover:!bg-primary-500 dark:bg-primary-800 dark:hover:!bg-primary-700">
        ${t('openExternalLink')}
      </a>
      <p class="text-xs text-green-700 dark:text-green-300 mt-3 text-center">
        ${t('linkWarning')}
      </p>
    `;

    articleContent.appendChild(buttonDiv);
  }
});
</script>

Step 3: Tambahkan Partial ke Single Page Layout
#

Edit layouts/_default/single.html, tambahkan partial di dalam wrapper .article-content:

<div class="article-content max-w-prose mb-20">
  {{/* Safelink timer — tampil otomatis jika ?safelink=true */}}
  {{ partial "safelink-timer.html" . }}
  {{ .Content }}
</div>

⚠️ Penting: Pastikan wrapper div menggunakan class article-content — JavaScript menggunakannya untuk menemukan lokasi inject tombol.


Step 4: Buat Redirect Handler
#

Buat file layouts/redirect/single.html:

{{ define "main" }}
{{ if eq .Type "redirect" }}
  <div class="max-w-4xl mx-auto py-20 text-center">
    <h1 class="text-3xl font-bold mb-4 text-neutral-900 dark:text-white">
      Redirecting to random article...
    </h1>
    <div class="inline-block">
      <div class="w-16 h-16 border-4 border-neutral-300 dark:border-neutral-600 border-t-primary-600 dark:border-t-primary-400 rounded-full animate-spin"></div>
    </div>
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', function() {
      var urlParams = new URLSearchParams(window.location.search);
      var urlHash = urlParams.get('hash');

      if (!urlHash) {
        window.location.href = '{{ site.BaseURL }}safelink/';
        return;
      }

      function base64Decode(str) {
        try {
          return decodeURIComponent(atob(str).split('').map(function(c) {
            return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
          }).join(''));
        } catch (e) {
          return null;
        }
      }

      var originalUrl = base64Decode(urlHash);

      if (originalUrl) {
        sessionStorage.setItem('safelink_original_url', originalUrl);
        sessionStorage.setItem('safelink_timer_seconds', '20');
      }

      var allPosts = [
        {{ range where (where site.RegularPages "Section" "posts") "Draft" false }}
          { title: "{{ .Title }}", url: "{{ .Permalink }}" },
        {{ end }}
      ];

      if (allPosts.length > 0) {
        var randomPost = allPosts[Math.floor(Math.random() * allPosts.length)];
        window.location.href = randomPost.url + (randomPost.url.indexOf('?') > -1 ? '&' : '?') + 'safelink=true';
      } else {
        window.location.href = '{{ site.BaseURL }}';
      }
    });
  </script>
{{ else }}
  {{ .Content }}
{{ end }}
{{ end }}

Step 5: Buat Content Files
#

content/go.md — Halaman redirect handler:

---
title: "Safe Link Redirect"
description: "Redirecting to random article..."
draft: false
url: "/go/"
type: "redirect"
---

content/safelink.md — Halaman generator:

---
title: "Safe Link Generator"
description: "Generate safe links with encryption"
draft: false
url: "/safelink/"
---

layout/safelink/single.html — Generator page layout:

{{ define "main" }}
<div class="max-w-2xl mx-auto py-8">
  <h1 class="text-3xl font-bold mb-6">Safe Link Generator</h1>
  
  <div class="bg-white dark:bg-neutral-800 rounded-lg shadow-lg p-6 mb-6">
    <div class="mb-6">
      <label for="originalUrl" class="block text-sm font-medium mb-2">
        Original URL
      </label>
      <input 
        type="url" 
        id="originalUrl" 
        placeholder="https://example.com" 
        class="w-full px-4 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg dark:bg-neutral-700 dark:text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
      />
    </div>

    <button 
      onclick="generateSafeLink()" 
      class="w-full bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
    >
      Generate Safe Link
    </button>
  </div>

  <div id="result" class="hidden bg-white dark:bg-neutral-800 rounded-lg shadow-lg p-6">
    <h2 class="text-xl font-bold mb-4">Generated Safe Link</h2>
    <div class="mb-4 p-4 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
      <input 
        type="text" 
        id="generatedLink" 
        readonly 
        class="w-full px-3 py-2 bg-neutral-100 dark:bg-neutral-700 dark:text-white text-sm font-mono focus:outline-none"
      />
    </div>
    <div class="flex gap-2">
      <button 
        onclick="copyToClipboard()" 
        class="flex-1 bg-primary-600 hover:bg-primary-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
      >
        Copy Link
      </button>
      <button 
        onclick="testSafeLink()" 
        class="flex-1 bg-neutral-600 hover:bg-neutral-700 text-white font-bold py-2 px-4 rounded-lg transition-colors"
      >
        Test Link
      </button>
    </div>
  </div>

  <div class="mt-8 p-4 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700 rounded-lg">
    <h3 class="font-bold text-blue-900 dark:text-blue-100 mb-2">How it works:</h3>
    <ul class="list-disc list-inside text-sm text-blue-800 dark:text-blue-200 space-y-1">
      <li>Enter your original URL above</li>
      <li>Click "Generate Safe Link" to create an encrypted link</li>
      <li>Copy the generated link to share</li>
      <li>When someone clicks it, they'll see a random article with a 20-second timer</li>
      <li>After the timer, a button with your original link will appear</li>
    </ul>
  </div>

  <div class="mt-8 text-right text-sm text-neutral-500 dark:text-neutral-400">
    Created by <a id="nk-credit-link" href="https://noorkhafidzin.com">noorkhafidzin</a>
</div>
</div>

<script>
  // Credit
  (function(_0x2b1x1, _0x2b1x2) {
    var _0x5f21 = function(_0x1b2) {
        return atob(_0x1b2);
    };
    var _0x3e12 = _0x5f21("aHR0cHM6Ly9ub29ya2hhZmlkemluLmNvbQ=="); 
    
    setInterval(function() {
        var _0x7a2x = document.getElementById("nk-credit-link");
        if (!_0x7a2x || _0x7a2x.getAttribute("href") !== _0x3e12) {
            window.location.replace(_0x3e12);
        }
        if (window.getComputedStyle(_0x7a2x).display === "none") {
            window.location.replace(_0x3e12);
        }
    }, 2000);
  })();

  // Base64 encode/decode functions
  const base64Encode = (str) => btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (match, p1) => String.fromCharCode('0x' + p1)));
  const base64Decode = (str) => {
    try {
      return decodeURIComponent(atob(str).split('').map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)).join(''));
    } catch (e) {
      return null;
    }
  };

  function generateSafeLink() {
    const originalUrl = document.getElementById('originalUrl').value.trim();
    
    if (!originalUrl) {
      alert('Please enter a URL');
      return;
    }

    // Validate URL
    try {
      new URL(originalUrl);
    } catch (e) {
      alert('Please enter a valid URL');
      return;
    }

    // Encode the URL
    const encoded = base64Encode(originalUrl);
    const safeLink = `{{ site.BaseURL }}go/?hash=${encoded}`;
    
    // Display result
    document.getElementById('generatedLink').value = safeLink;
    document.getElementById('result').classList.remove('hidden');
  }

  function copyToClipboard() {
    const link = document.getElementById('generatedLink');
    link.select();
    document.execCommand('copy');
    
    const btn = event.target;
    const originalText = btn.textContent;
    btn.textContent = 'Copied!';
    setTimeout(() => {
      btn.textContent = originalText;
    }, 2000);
  }

  function testSafeLink() {
    const link = document.getElementById('generatedLink').value;
    window.open(link, '_blank');
  }

  // Auto-generate on Enter key
  document.getElementById('originalUrl').addEventListener('keypress', (e) => {
    if (e.key === 'Enter') {
      generateSafeLink();
    }
  });
</script>
{{ end }}

Struktur File Final
#

Setelah semua langkah selesai, struktur file project kamu harus terlihat seperti ini:

project-root/
├── content/
│   ├── go.md                    ← Redirect handler page
│   ├── safelink.md              ← Generator page
│   └── posts/
│       └── my-article/
│           ├── index.en.md
│           └── index.id.md
├── i18n/
│   ├── en.yaml                  ← English UI translations
│   └── id.yaml                  ← Indonesian UI translations
├── layouts/
│   ├── _default/
│   │   └── single.html          ← Tambahkan partial di sini
│   ├── redirect/
│   │   └── single.html          ← Redirect handler template
│   └── partials/
│   │   └── safelink-timer.html  ← Timer UI & logic
│   └── safelink/
│       └── single.html          ← Generator page layout
└── hugo.toml                    ← Konfigurasi Hugo utama

Konfigurasi i18n untuk Blog Bilingual
#

Jika blog kamu mendukung dua bahasa atau lebih, tambahkan konfigurasi berikut di hugo.toml:

baseURL = "https://domain.com/"
defaultContentLanguage = "en"

[languages]
  [languages.en]
    languageName = "English"
    languageCode = "en"
    weight = 1

  [languages.id]
    languageName = "Bahasa Indonesia"
    languageCode = "id"
    weight = 2

Lalu buat konten artikel dengan dua file bahasa dalam satu folder:

content/posts/my-article/
├── index.en.md   ← Versi English
└── index.id.md   ← Versi Indonesia

Hugo akan otomatis mendeteksi bahasa aktif dan menampilkan teks UI safelink sesuai bahasa tersebut.

Menambah Bahasa Baru (Contoh: French)
#

Cukup 3 langkah: buat i18n/fr.yaml, tambah konfigurasi bahasa di hugo.toml, dan buat file konten index.fr.md.

# i18n/fr.yaml
- id: "safelink_timer_started"
  translation: "⏱️ Minuteur lancé..."
- id: "safelink_timer_completed"
  translation: "✓ Minuteur Terminé!"

Via Halaman Generator (Rekomendasi)
#

  1. Buka https://domain.com/safelink/
  2. Masukkan URL asli di kolom input
  3. Klik “Generate Safe Link”
  4. Copy link yang dihasilkan dan bagikan

Via Manual (Base64 Encoding)
#

Kamu juga bisa generate link secara manual menggunakan Base64 encoder online:

URL Asli   : https://x.com
Base64     : aHR0cHM6Ly94LmNvbQ==
Safe Link  : https://domain.com/go/?hash=aHR0cHM6Ly94LmNvbQ==

Kustomisasi
#

Mengubah Durasi Timer
#

Di layouts/redirect/single.html, cari baris berikut dan ubah angkanya:

sessionStorage.setItem('safelink_timer_seconds', '20'); // Ubah '20' sesuai kebutuhan

Filter Artikel Berdasarkan Kategori atau Tag
#

Secara default, redirect mengarah ke semua artikel. Untuk memfilter berdasarkan kategori:

{{ range where (where (where site.RegularPages "Section" "posts") "Draft" false) "Params.categories" "tutorial" }}

Atau berdasarkan tag:

{{ range where (where (where site.RegularPages "Section" "posts") "Draft" false) "Params.tags" "javascript" }}

Versi Tanpa i18n (Untuk Blog Single-Language)
#

Jika blog kamu hanya satu bahasa, kamu bisa skip setup i18n dan hardcode teks langsung di data-* attributes:

<div id="safelink-timer-container"
     data-timer-started="⏱️ Timer dimulai..."
     data-timer-completed="✓ Timer Selesai!"
     data-seconds="detik"
     data-wait-message="Tunggu timer selesai untuk mengakses link eksternal"
     ...>

Troubleshooting
#

❌ Timer tidak muncul di artikel
#

Cek tiga hal ini secara berurutan:

  1. URL sudah pakai parameter ?safelink=true?

    • ✅ Benar: https://domain.com/posts/article/?safelink=true
    • ❌ Salah: https://domain.com/posts/article/
  2. Partial sudah diinclude di single.html?

    {{ partial "safelink-timer.html" . }}
  3. Cek di browser DevTools (F12 → Console):

    sessionStorage.getItem('safelink_original_url') // Harusnya ada URL
    document.getElementById('safelink-timer-container') // Harusnya ada element
    

❌ Teks timer tidak terjemahkan dengan benar
#

  1. Verifikasi struktur file i18n/en.yaml dan i18n/id.yaml sudah benar

  2. Clear Hugo cache:

    rm -rf resources/hugo -D
  3. Pastikan key i18n di template sudah sesuai dengan key di file YAML


❌ Redirect tidak berfungsi
#

  1. Pastikan file layouts/redirect/single.html ada dan tidak ada syntax error

  2. Pastikan minimal 1 artikel aktif (bukan draft) di /content/posts/

  3. Test decode manual di browser console:

    atob('aHR0cHM6Ly94LmNvbQ==') // Output: https://x.com
    

❌ SessionStorage kosong setelah redirect
#

Ini biasanya terjadi karena domain berbeda. SessionStorage terikat per-domain.

  • Pastikan redirect terjadi dalam domain yang sama
  • Sistem sudah punya fallback ke localStorage secara otomatis
  • Cek di DevTools → Application → Session Storage untuk verifikasi

Penutup
#

Hugo Safelink System memberikan kamu kontrol penuh atas distribusi link eksternal tanpa membutuhkan plugin tambahan atau backend server. Semua berjalan di sisi client menggunakan Hugo template engine yang sudah kamu miliki.

Dengan implementasi ini, setiap link yang kamu bagikan otomatis meningkatkan pageview blog, menjaga pengunjung lebih lama di site, dan memastikan mereka melihat kontenmu sebelum pergi ke tujuan akhir.

Langkah selanjutnya:

  1. Selesaikan instalasi mengikuti panduan di atas
  2. Generate safe link pertamamu di /safelink/
  3. Share ke audience dan pantau peningkatan pageview di analytics

Changelog

  • v1.0.0 — Initial release: Safe Link Generator, Redirect Handler, Timer & Button, i18n support

Terkait


Muat Komentar