Skip to main content

Comments

Post comments are self-hosted and fully static — no third-party comment service, no database, no JavaScript framework. Submissions flow through a Cloudflare Pages Function that validates input, verifies a Cloudflare Turnstile challenge, runs the content through Workers AI (Gemma 4) for spam classification, and opens a GitHub pull request with the comment as YAML front matter in the post’s source file. Merging the PR triggers a Hugo rebuild that renders the comment as static HTML.

StepWhat happens
Form submissionA native <dialog> collects name, email (optional), website (optional), and Markdown content; generates a UUID for idempotent retries
Rate limitingIn-worker sliding window (5 per IP per 60 seconds) rejects excess submissions before any further processing
Bot protectionCloudflare Turnstile token is verified server-side against Cloudflare’s API; submissions that fail the challenge are rejected with HTTP 403
Path resolutionAn 8-character MD5 hash maps to the post’s source file via a build-time comment-map.json — the client never sees internal paths
Spam detectionWorkers AI classifies the comment at a 0.90 confidence threshold; AI errors are fail-open — if Workers AI is unreachable or returns an unparseable response, the comment passes through rather than being silently dropped
GitHub PRThe function creates a branch, commits the comment into the post’s YAML front matter, and opens a PR for manual review
PublishingMerging the PR triggers a Hugo build; comments.html iterates the comments: front matter array and renders each entry via markdownify
Draft persistence uses per-post cookies (7-day TTL) and author memory lives in localStorage — closing the dialog mid-draft won’t lose your work.

Eight security layers provide defense in depth: rate limiting, Turnstile bot filtering, server-side field validation, HTML tag stripping, source hash obfuscation, SRI on CDN scripts (marked, DOMPurify), Workers AI spam detection, and manual PR review as the final human gate.

Comment API #

The Cloudflare Pages Function that handles submissions (functions/api/comment.ts):

/**
 * POST /api/comment
 *
 * Accepts a new comment submission, validates it with Cloudflare Turnstile,
 * screens for spam via Workers AI, then opens a GitHub PR to add the comment
 * to the post's front matter for review.
 *
 * Required environment bindings (set in Cloudflare Pages dashboard):
 *   TURNSTILE_SECRET_KEY  — Cloudflare Turnstile secret key
 *   GITHUB_TOKEN          — GitHub PAT with repo scope
 *   GITHUB_REPO           — GitHub repo (owner/name)
 *   AI                    — Workers AI binding
 *   ASSETS                — Cloudflare Pages static assets binding
 */

interface Env {
  TURNSTILE_SECRET_KEY: string;
  GITHUB_TOKEN: string;
  GITHUB_REPO?: string;
  AI: {
    run(
      model: string,
      options: {
        messages: Array<{ role: string; content: string }>;
        max_tokens: number;
        chat_template_kwargs?: { enable_thinking: boolean };
        response_format?: { type: string };
      },
    ): Promise<{ response?: unknown; choices?: Array<{ message?: { content?: string } }> }>;
  };
  ASSETS: {
    fetch(req: Request | string): Promise<Response>;
  };
}

interface CommentBody {
  name?: string;
  email?: string;
  url?: string;
  content?: string;
  sourceHash?: string;
  turnstileToken?: string;
  commentId?: string;
}

interface ValidatedComment {
  sourceHash: string;
  author: string;
  email: string;
  url: string;
  content: string;
  turnstileToken: string;
}

interface SpamResult {
  isSpam: boolean;
  confidence: number;
}

interface CommentData {
  filePath: string;
  postUrl: string;
  author: string;
  email?: string;
  url?: string;
  content: string;
  commentId: string;
  spamResult: SpamResult;
}

const GITHUB_API = 'https://api.github.com';
const TURNSTILE_VERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const SPAM_CONFIDENCE_THRESHOLD = 0.90;

// Module-level cache: comment-map.json is static (changes only on deploy).
// Cloudflare recycles isolates on deploy, so this cache is never stale.
let cachedCommentMap: Record<string, { path: string; url: string }> | null = null;

// In-memory rate limiting: 5 submissions per IP per 60 seconds (per-isolate)
const RATE_LIMIT_MAX = 5;
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_COOLDOWN_S = 60;
const rateLimitMap = new Map<string, number[]>();

function checkRateLimit(ip: string): { limited: boolean; retryAfter: number } {
  const now = Date.now();
  const windowStart = now - RATE_LIMIT_WINDOW_MS;
  const timestamps = (rateLimitMap.get(ip) ?? []).filter(t => t > windowStart);
  if (timestamps.length >= RATE_LIMIT_MAX) {
    const retryAfter = Math.max(1, Math.ceil((timestamps[0] + RATE_LIMIT_WINDOW_MS - now) / 1000));
    return { limited: true, retryAfter };
  }
  timestamps.push(now);
  rateLimitMap.set(ip, timestamps);
  // Prune stale entries once the map grows large
  if (rateLimitMap.size > 1000) {
    for (const [k, v] of rateLimitMap) {
      if (v.every(t => t <= windowStart)) rateLimitMap.delete(k);
    }
  }
  return { limited: false, retryAfter: 0 };
}

export async function onRequestPost(context: { request: Request; env: Env }): Promise<Response> {
  const { request, env } = context;

  // Rate limit: 5 submissions per IP per 60 seconds
  const clientIp = request.headers.get('CF-Connecting-IP');
  if (!clientIp) {
    // CF-Connecting-IP is always present when routed through Cloudflare; its absence
    // means the function is being invoked directly (dev, testing). Skip rate limiting.
    console.warn('[comment] CF-Connecting-IP header missing — rate limiting skipped');
  }
  let rateCheck: { limited: boolean; retryAfter: number } = { limited: false, retryAfter: 0 };
  if (clientIp) {
    try {
      rateCheck = checkRateLimit(clientIp);
    } catch (err) {
      console.error('[comment] Rate limit check failed:', (err as Error).message);
      // Fail-open: allow the request through rather than blocking all traffic
    }
  }
  if (rateCheck.limited) {
    return new Response(JSON.stringify({ error: 'Too many submissions. Please try again later.' }), {
      status: 429,
      headers: { 'Content-Type': 'application/json', 'Retry-After': String(rateCheck.retryAfter) },
    });
  }

  // Guard: validate required env bindings before any work
  const missingBindings = [
    !env.TURNSTILE_SECRET_KEY && 'TURNSTILE_SECRET_KEY',
    !env.GITHUB_TOKEN && 'GITHUB_TOKEN',
    !env.GITHUB_REPO && 'GITHUB_REPO',
  ].filter(Boolean);
  if (missingBindings.length > 0) {
    console.error('[comment] Missing required env bindings:', missingBindings.join(', '));
    return jsonError('Failed to submit comment. Please try again later.', 500);
  }

  let body: CommentBody;
  try {
    body = await request.json();
  } catch (err) {
    console.warn('[comment] Malformed request body:', (err as Error).message);
    return jsonError('Invalid request body', 400);
  }

  const { name, email, url, content, sourceHash, turnstileToken, commentId } = body;

  if (!name?.trim() || !content?.trim() || !sourceHash?.trim() || !turnstileToken) {
    return jsonError('Missing required fields', 400);
  }
  if (!/^[a-f0-9]{8}$/.test(sourceHash.trim())) {
    return jsonError('Invalid source', 400);
  }
  if (name.trim().length > 100 || content.trim().length > 2000) {
    return jsonError('Input exceeds maximum length', 400);
  }
  if (url && !/^https?:\/\/.+/.test(url.trim())) {
    return jsonError('Invalid URL format', 400);
  }
  if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
    return jsonError('Invalid email format', 400);
  }
  if (!commentId || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(commentId)) {
    return jsonError('Missing or invalid commentId', 400);
  }

  // Build validated object — trim and sanitize once
  const validated: ValidatedComment = {
    sourceHash: sourceHash.trim(),
    author: name.trim(),
    email: email?.trim() ?? '',
    url: url?.trim() ?? '',
    content: stripHtml(content.trim()),
    turnstileToken,
  };

  // 1. Verify Turnstile token
  try {
    const turnstileRes = await fetch(TURNSTILE_VERIFY, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        secret: env.TURNSTILE_SECRET_KEY,
        response: validated.turnstileToken,
        remoteip: request.headers.get('CF-Connecting-IP') ?? '',
      }),
    });
    const turnstileData = await turnstileRes.json() as { success: boolean };
    if (!turnstileData.success) {
      return jsonError('Security check failed', 403);
    }
  } catch (err) {
    console.error('[comment] Turnstile verification failed:', (err as Error).message);
    return jsonError('Security verification unavailable. Please try again later.', 503);
  }

  // 2. Resolve source hash → file path + post URL
  let filePath: string;
  let postUrl: string;
  try {
    if (!cachedCommentMap) {
      const mapRes = await env.ASSETS.fetch('https://placeholder/comment-map.json');
      if (!mapRes.ok) throw new Error(`comment-map.json not found (${mapRes.status})`);
      cachedCommentMap = await mapRes.json() as Record<string, { path: string; url: string }>;
    }
    const entry = cachedCommentMap[validated.sourceHash];
    if (!entry) return jsonError('Unknown source', 400);
    if (typeof entry !== 'object' || typeof entry.path !== 'string' || typeof entry.url !== 'string') {
      console.error('[comment] comment-map.json entry has unexpected shape:', typeof entry, 'hash:', validated.sourceHash);
      return jsonError('Failed to process comment. Please try again later.', 500);
    }
    if (!entry.url.startsWith('/')) {
      console.error('[comment] comment-map.json entry.url missing leading slash:', entry.url);
      return jsonError('Failed to process comment. Please try again later.', 500);
    }
    filePath = entry.path;
    postUrl = `https://george.tsiokos.com${entry.url}`;
  } catch (err) {
    console.error('[comment] Failed to resolve source hash:', (err as Error).message);
    return jsonError('Failed to process comment. Please try again later.', 500);
  }

  // 3. Spam detection via Workers AI
  let spamResult: SpamResult;
  try {
    spamResult = await detectSpam(env.AI, validated.author, validated.url, validated.content);
  } catch (err) {
    // detectSpam has its own internal catch, but guard here against unexpected throws
    console.error('[comment] Unexpected spam check failure, allowing through:', (err as Error).message);
    spamResult = { isSpam: false, confidence: 0 };
  }
  if (spamResult.isSpam) {
    console.log('[comment] SPAM', JSON.stringify({
      confidence: spamResult.confidence,
      author: validated.author,
      url: validated.url,
      sourceHash: validated.sourceHash,
      content: validated.content,
    }));
    return jsonError('Comment was identified as spam', 422);
  }

  // 4. Create GitHub PR
  try {
    const prUrl = await createCommentPR(env.GITHUB_TOKEN, env.GITHUB_REPO, env.AI, {
      filePath,
      postUrl,
      author: validated.author,
      email: validated.email || undefined,
      url: validated.url || undefined,
      content: validated.content,
      commentId,
      spamResult,
    });
    console.log('[comment] PR created:', prUrl, 'commentId:', commentId);
    return jsonResponse({ success: true, message: 'Comment submitted for review' }, 200);
  } catch (err) {
    console.error('[comment] GitHub PR creation failed:', (err as Error).message);
    return jsonError('Failed to submit comment. Please try again later.', 500);
  }
}

/**
 * Extract text content from a Workers AI response.
 * Handles both legacy format ({ response: string }) and OpenAI chat completion format
 * ({ choices: [{ message: { content: string } }] }).
 */
function extractAIText(result: {
  response?: unknown;
  choices?: Array<{ message?: { content?: string } }>;
}): string {
  if (typeof result?.response === 'string') return result.response;
  const content = result?.choices?.[0]?.message?.content;
  return typeof content === 'string' ? content : '';
}

/**
 * Run comment through Workers AI to detect spam.
 * Uses gemma-4-26b-a4b-it with a structured prompt.
 */
async function detectSpam(
  ai: Env['AI'],
  author: string,
  url: string,
  content: string,
): Promise<SpamResult> {
  try {
    const result = await ai.run('@cf/google/gemma-4-26b-a4b-it', {
      messages: [
        {
          role: 'system',
          content: 'You are a spam classifier for a technical blog. Classify the following blog comment as SPAM or NOT_SPAM. Respond with a JSON object: {"label": "SPAM"|"NOT_SPAM", "confidence": 0.0-1.0}. Nothing else.',
        },
        {
          role: 'user',
          content: `Author: ${author}\nURL: ${url || '(none)'}\nComment: ${content}`,
        },
      ],
      max_tokens: 64,
      chat_template_kwargs: { enable_thinking: false },
      response_format: { type: 'json_object' },
    });
    const text = extractAIText(result).trim();
    const match = text.match(/\{[^}]+\}/);
    if (!match) {
      console.warn('[comment] AI returned unparseable response:', text.slice(0, 100));
      return { isSpam: false, confidence: 0 };
    }
    const parsed = JSON.parse(match[0]) as { label?: string; confidence?: number };
    const confidence = Math.max(0, Math.min(1, parseFloat(String(parsed.confidence)) || 0));
    return {
      isSpam: parsed.label === 'SPAM' && confidence >= SPAM_CONFIDENCE_THRESHOLD,
      confidence,
    };
  } catch (err) {
    // On AI failure, allow the comment through rather than silently dropping it
    const errType = (err as Error).constructor?.name ?? 'Error';
    console.error(`[comment] AI spam check failed (${errType}), allowing through:`, (err as Error).message);
    return { isSpam: false, confidence: 0 };
  }
}

/**
 * Fetch from the GitHub API; throws with response body on failure for
 * actionable error messages in logs.
 */
async function githubFetch(url: string, init: RequestInit): Promise<Response> {
  const res = await fetch(url, init);
  if (!res.ok) {
    const body = await res.text().catch(() => '');
    throw new Error(`GitHub API ${res.status}: ${body.slice(0, 200)}`);
  }
  return res;
}

/**
 * Create a GitHub PR adding the comment to the post's front matter.
 */
async function createCommentPR(
  token: string,
  repo: string,
  ai: Env['AI'],
  { filePath, postUrl, author, email, url, content, commentId, spamResult }: CommentData,
): Promise<string> {
  const headers: Record<string, string> = {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': 'george.tsiokos.com/comment-worker',
  };

  // Fetch file content and main branch SHA in parallel (no dependency between them)
  const [fileRes, mainRes] = await Promise.all([
    githubFetch(`${GITHUB_API}/repos/${repo}/contents/${filePath}`, { headers }),
    githubFetch(`${GITHUB_API}/repos/${repo}/git/ref/heads/main`, { headers }),
  ]);
  const [fileData, mainSha] = await Promise.all([
    fileRes.json() as Promise<{ content: string; sha: string }>,
    mainRes.json().then((d: { object: { sha: string } }) => d.object.sha),
  ]);

  let currentContent: string;
  try {
    const rawBinary = atob(fileData.content.replace(/\n/g, ''));
    const bytes = Uint8Array.from(rawBinary, c => c.charCodeAt(0));
    currentContent = new TextDecoder().decode(bytes);
  } catch (err) {
    throw new Error(`Failed to decode file content for ${filePath}: ${(err as Error).message}`);
  }
  const fileSha = fileData.sha;
  const postTitle = extractPostTitle(currentContent);

  // Inject comment into front matter
  const now = new Date().toISOString().slice(0, 19); // YYYY-MM-DDTHH:MM:SS
  const updatedContent = addCommentToFrontMatter(currentContent, { author, email, url, date: now, content });

  // Create a branch — client UUID is the unique suffix (required; idempotency gate).
  const suffix = commentId;
  const slugPart = filePath.replace(/^content\//, '').replace(/\.markdown$/, '').replace(/[^a-z0-9-]/g, '-').slice(0, 50);
  const branch = `comment/${slugPart}-${suffix}`;

  // Start PR title generation now — depends only on postTitle (already available),
  // so it can run in parallel with branch creation and commit.
  const prTitlePromise = generatePRTitle(ai, postTitle, author, email, url, content);

  // Create branch — idempotent: skip if already exists from a prior attempt
  const createRefRes = await fetch(`${GITHUB_API}/repos/${repo}/git/refs`, {
    method: 'POST',
    headers,
    body: JSON.stringify({ ref: `refs/heads/${branch}`, sha: mainSha }),
  });
  if (!createRefRes.ok) {
    if (createRefRes.status === 422) {
      const body = await createRefRes.text().catch(() => '');
      if (body.includes('Reference already exists')) {
        console.log('[comment] Branch already exists, continuing with retry:', branch);
      } else {
        throw new Error(`GitHub API 422: ${body.slice(0, 200)}`);
      }
    } else {
      const body = await createRefRes.text().catch(() => '');
      throw new Error(`GitHub API ${createRefRes.status}: ${body.slice(0, 200)}`);
    }
  }

  // Commit file — idempotent: skip if already committed on this branch from a prior attempt
  const rawEncoded = new TextEncoder().encode(updatedContent);
  const commitRes = await fetch(`${GITHUB_API}/repos/${repo}/contents/${filePath}`, {
    method: 'PUT',
    headers,
    body: JSON.stringify({
      message: `Add comment by ${author}`,
      content: btoa(Array.from(rawEncoded, b => String.fromCharCode(b)).join('')),
      sha: fileSha,
      branch,
    }),
  });
  if (!commitRes.ok) {
    const commitBody = await commitRes.text().catch(() => '');
    // 409 with a SHA-related message means the file was already committed on this branch in a prior attempt
    if (commitRes.status === 409 && commitBody.includes('"sha"')) {
      console.log('[comment] Commit already exists on branch, continuing with retry:', branch);
    } else {
      throw new Error(`GitHub API ${commitRes.status}: ${commitBody.slice(0, 200)}`);
    }
  }

  // Await PR title (likely already resolved while branch/commit were in flight)
  const prTitle = await prTitlePromise;
  const prRes = await fetch(`${GITHUB_API}/repos/${repo}/pulls`, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      title: prTitle,
      head: branch,
      base: 'main',
      // Design decision: user-supplied fields are intentionally not Markdown-escaped.
      // Comments support Markdown syntax (rendered via Hugo's markdownify in comments.html).
      // The PR body is only visible to the repo owner during review — not to site visitors.
      // HTML is stripped server-side (stripHtml) before reaching the PR; Markdown is not.
      // [no-review] at the top suppresses the claude-code-review.yml workflow for comment
      // PRs — human review only, no automated AI code review pass needed.
      body: `[no-review]\n\n> ${postUrl}\n>\n> Author: ${author}\n> Email: ${email || '(none)'}\n> URL: ${url || '(none)'}\n>\n> ${content.split('\n').join('\n> ')}\n\n**Spam detection**\n\`\`\`json\n${JSON.stringify(spamResult, null, 2)}\n\`\`\``,
    }),
  });
  if (!prRes.ok) {
    if (prRes.status === 422) {
      const prBody = await prRes.text().catch(() => '');
      if (prBody.includes('A pull request already exists')) {
        console.log('[comment] PR already exists for branch, returning success:', branch);
        return `https://github.com/${repo}/pulls`;
      }
      throw new Error(`GitHub API 422: ${prBody.slice(0, 200)}`);
    }
    const prBody = await prRes.text().catch(() => '');
    throw new Error(`GitHub API ${prRes.status}: ${prBody.slice(0, 200)}`);
  }
  const prData = await prRes.json() as { html_url?: string };
  if (!prData.html_url) throw new Error('GitHub PR response missing html_url');
  return prData.html_url;
}

/**
 * Use Workers AI to generate a descriptive PR title for a new comment.
 * author, email, and url are forwarded to the model so the title can
 * identify the commenter and reflect their context.
 * Falls back to a template string if AI is unavailable or returns an unusable result.
 */
async function generatePRTitle(
  ai: Env['AI'],
  postTitle: string,
  author: string,
  email: string | undefined,
  url: string | undefined,
  content: string,
): Promise<string> {
  const fallback = `Comment by ${author} on "${postTitle}"`;
  try {
    const result = await ai.run('@cf/google/gemma-4-26b-a4b-it', {
      messages: [
        {
          role: 'system',
          content: 'Generate a descriptive GitHub PR title for a new blog comment. The title must be under 256 characters and capture who commented, the gist of their comment, and the post title. Example: "David Collantes: sold on the UniFi Travel Router — Claude Coding at 35,000 Feet". Respond with ONLY the title text, nothing else.',
        },
        {
          role: 'user',
          content: `Post: ${postTitle}\nAuthor: ${author}${email ? `\nEmail: ${email}` : ''}${url ? `\nURL: ${url}` : ''}\nComment: ${content}`,
        },
      ],
      max_tokens: 128,
      chat_template_kwargs: { enable_thinking: false },
    });
    const text = extractAIText(result).trim().replace(/^["']|["']$/g, '');
    if (!text || text.length > 256) return fallback;
    return text;
  } catch (err) {
    console.error('[comment] AI PR title generation failed, using fallback:', (err as Error).message);
    return fallback;
  }
}

/**
 * Extract the post title from YAML front matter.
 */
export function extractPostTitle(fileContent: string): string {
  const match = fileContent.match(/^title:\s*['"]?(.*?)['"]?\s*$/m);
  return match?.[1] ?? 'Unknown Post';
}

/**
 * Parse front matter, append the comment to the `comments:` array,
 * and return the updated file content. When `comments:` exists, walks
 * the front matter lines to find the end of that block (the next
 * top-level key), so the entry is never placed under the wrong key.
 */
export function addCommentToFrontMatter(
  fileContent: string,
  { author, email, url, date, content }: { author: string; email?: string; url?: string; date: string; content: string },
): string {
  const fmMatch = fileContent.match(/^---\n([\s\S]*?)\n---\n/);
  if (!fmMatch) throw new Error('No front matter found in file');

  const commentYaml = buildCommentYaml({ author, email, url, date, content });
  // fmEnd is the first byte after the closing ---\n
  const fmEnd = fmMatch[0].length;

  if (/^comments:/m.test(fmMatch[1])) {
    // Find where the comments block ends: the first top-level key after `comments:`.
    // Top-level keys start with a non-whitespace character; indented lines and blank
    // lines belong to the current block.
    const lines = fmMatch[1].split('\n');
    let inComments = false;
    let insertLineIdx = lines.length; // default: end of front matter body

    for (let i = 0; i < lines.length; i++) {
      if (!inComments) {
        if (/^comments:/.test(lines[i])) inComments = true;
      } else if (lines[i] !== '' && /^\S/.test(lines[i])) {
        insertLineIdx = i;
        break;
      }
    }

    const before = lines.slice(0, insertLineIdx).join('\n');
    const after = lines.slice(insertLineIdx).join('\n');
    const newFmBody = after.length > 0
      ? before + '\n' + commentYaml + '\n' + after
      : before + '\n' + commentYaml;

    return '---\n' + newFmBody + '\n---\n' + fileContent.slice(fmEnd);
  } else {
    // No existing comments key — insert it at the end of the front matter.
    // insertAt is the position of the \n immediately before the closing ---\n (5 = "\n---\n")
    const insertAt = fmEnd - 5;
    return fileContent.slice(0, insertAt) + '\ncomments:\n' + commentYaml + '\n---\n' + fileContent.slice(fmEnd);
  }
}

/**
 * Build YAML for a single comment entry (2-space indented, list item).
 */
export function buildCommentYaml(
  { author, email, url, date, content }: { author: string; email?: string; url?: string; date: string; content: string },
): string {
  const lines: string[] = [];
  lines.push(`  - author: ${yamlScalar(author)}`);
  if (url) lines.push(`    url: ${yamlScalar(url)}`);
  if (email) lines.push(`    email: ${yamlScalar(email)}`);
  lines.push(`    date: ${date}`);
  lines.push(`    content: |`);
  for (const line of content.split('\n')) {
    lines.push(`      ${line}`);
  }
  return lines.join('\n');
}

export function stripHtml(text: string): string {
  // Restrict to tag-like patterns (letter, /, !, ?) after < so that
  // math comparisons like "a < b" are not corrupted. The optional >
  // catches unclosed tags like <img src=x onerror=alert(1) (no closing >).
  return text.replace(/<[a-zA-Z/!?][^>]*>?/g, '');
}

export function yamlScalar(value: string): string {
  // Quote strings containing special characters or YAML 1.1 reserved words
  // (null, true, false, yes, no, on, off) to prevent type coercion by go-yaml.
  if (/[:'"#{}\[\],&*?|<>=!%@`\n\t]/.test(value) || /^(null|true|false|yes|no|on|off)$/i.test(value)) {
    return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t')}"`;
  }
  return value;
}

function jsonResponse(data: unknown, status: number): Response {
  return new Response(JSON.stringify(data), {
    status,
    headers: { 'Content-Type': 'application/json' },
  });
}

function jsonError(message: string, status: number): Response {
  return jsonResponse({ error: message }, status);
}

Comment Form #

The client-side <dialog> with inline JavaScript for validation, draft persistence, Markdown preview, and Turnstile integration (layouts/_partials/comments.html):

{{/* Comment display */}}
{{ with .Params.comments }}
<div class="mt-6">
  <h2 class="text-2xl font-bold mb-4 text-neutral-900 dark:text-neutral-100">Comments</h2>
  {{ range . }}
  <div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-4">
    <div class="flex items-start justify-between mb-1">
      <div class="font-semibold text-neutral-900 dark:text-neutral-100">
        {{- $trusted := false -}}
        {{- with urls.Parse .url -}}
          {{- $host := strings.TrimPrefix "www." .Host | lower -}}
          {{- $trustedHosts := (index hugo.Data "trusted-urls").hosts -}}
          {{- if $trustedHosts -}}
            {{- range $trustedHosts -}}
              {{- if eq $host (strings.TrimPrefix "www." . | lower) -}}
                {{- $trusted = true -}}
              {{- end -}}
            {{- end -}}
          {{- end -}}
        {{- end -}}
        {{ if .url }}
          {{ if $trusted }}
            <a href="{{ .url }}" class="text-primary-600 dark:text-primary-400 hover:underline">{{ .author }}</a>
          {{ else }}
            <a href="{{ .url }}" rel="nofollow noopener" class="text-primary-600 dark:text-primary-400 hover:underline">{{ .author }}</a>
          {{ end }}
        {{ else }}
          {{ .author }}
        {{ end }}
      </div>
      <time datetime="{{ .date }}" class="text-sm text-neutral-500 dark:text-neutral-400 shrink-0 ml-4">
        {{ dateFormat "January 2, 2006" .date }}
      </time>
    </div>
    <div class="prose dark:prose-invert max-w-none text-sm mt-2">{{ .content | markdownify }}</div>
  </div>
  {{ end }}
</div>
{{ end }}

{{/* Comment button */}}
<div class="mt-6">
  <button id="commentButton" type="button" class="btn btn-primary">
    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M10 2c-2.236 0-4.43.18-6.57.524C1.993 2.755 1 4.014 1 5.426v5.148c0 1.413.993 2.67 2.43 2.902.848.137 1.705.248 2.57.331v3.443a.75.75 0 0 0 1.28.53l3.58-3.579a.78.78 0 0 1 .527-.224 41.202 41.202 0 0 0 5.183-.5c1.437-.232 2.43-1.49 2.43-2.903V5.426c0-1.413-.993-2.67-2.43-2.902A41.289 41.289 0 0 0 10 2Zm0 7a1 1 0 1 0 0-2 1 1 0 0 0 0 2ZM8 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0Zm5 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" /></svg>
    <span>Leave a comment<span id="comment-draft-indicator"></span></span>
  </button>
</div>

{{/* Comment dialog. The .flex utility is NOT applied here: it would override the UA
     `dialog { display: none }` rule and leave the closed dialog visible in normal flow.
     Layout is driven from #comment-dialog[open] in custom.css. */}}
<dialog id="comment-dialog" class="dialog-spring" data-component="dialog" data-source="{{ substr (md5 .File.Path) 0 8 }}">
  <div class="px-6 pt-6 pb-4 border-b border-neutral-200 dark:border-neutral-600">
    <h3 class="text-xl font-bold">Leave a comment</h3>
  </div>
  <form id="comment-form" novalidate class="px-6 py-4 overflow-y-auto flex-1">
    <div class="mb-4">
      <label for="comment-name" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
        Name <span aria-hidden="true" class="text-red-500">*</span>
      </label>
      <input type="text" id="comment-name" name="name" required maxlength="100" autocomplete="name"
        class="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none">
    </div>
    <div class="mb-4">
      <label for="comment-email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
        Email <span class="text-neutral-400 dark:text-neutral-500 font-normal">(optional, not published)</span>
      </label>
      <input type="email" id="comment-email" name="email" maxlength="200" autocomplete="email"
        class="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none">
    </div>
    <div class="mb-4">
      <label for="comment-url" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
        Website <span class="text-neutral-400 dark:text-neutral-500 font-normal">(optional)</span>
      </label>
      <input type="url" id="comment-url" name="url" maxlength="200" autocomplete="url" placeholder="https://"
        class="w-full rounded-md border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none">
    </div>
    <div class="mb-4">
      <label for="comment-content" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
        Comment <span aria-hidden="true" class="text-red-500">*</span>
        <span class="text-neutral-400 dark:text-neutral-500 font-normal">(Markdown supported)</span>
      </label>
      <div class="flex flex-col sm:flex-row gap-4">
        <div class="flex-1 min-w-0">
          <div class="comment-toolbar flex gap-1 border border-neutral-200 dark:border-neutral-600 border-b-0 rounded-t-md p-1.5 bg-neutral-100 dark:bg-neutral-800">
            <button type="button" class="comment-toolbar-btn px-2 py-1 text-xs rounded border border-transparent hover:bg-neutral-200 hover:border-neutral-300 text-neutral-600 dark:text-neutral-400 font-mono transition-colors" data-format="bold" title="Bold (**text**)" aria-label="Bold"><strong>B</strong></button>
            <button type="button" class="comment-toolbar-btn px-2 py-1 text-xs rounded border border-transparent hover:bg-neutral-200 hover:border-neutral-300 text-neutral-600 dark:text-neutral-400 font-mono transition-colors" data-format="italic" title="Italic (*text*)" aria-label="Italic"><em>I</em></button>
            <button type="button" class="comment-toolbar-btn px-2 py-1 text-xs rounded border border-transparent hover:bg-neutral-200 hover:border-neutral-300 text-neutral-600 dark:text-neutral-400 font-mono transition-colors" data-format="link" title="Link ([text](url))" aria-label="Insert link">Link</button>
            <button type="button" class="comment-toolbar-btn px-2 py-1 text-xs rounded border border-transparent hover:bg-neutral-200 hover:border-neutral-300 text-neutral-600 dark:text-neutral-400 font-mono transition-colors" data-format="code" title="Inline code (`text`)" aria-label="Inline code">Code</button>
          </div>
          <textarea id="comment-content" name="content" required maxlength="2000" rows="8"
            class="w-full border border-neutral-300 dark:border-neutral-600 rounded-b-md bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 px-3 py-2 text-sm focus:outline-none resize-y"></textarea>
        </div>
        <div class="flex-1 min-w-0 border border-neutral-200 dark:border-neutral-700 rounded-md overflow-hidden flex flex-col bg-neutral-100 dark:bg-neutral-800">
          <div id="comment-preview" class="prose dark:prose-invert max-w-none p-3 text-sm flex-1" aria-label="Markdown preview"></div>
          <span class="block text-xs text-center text-neutral-400 dark:text-neutral-500 py-1 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-100 dark:bg-neutral-700">Preview</span>
        </div>
      </div>
    </div>
    <div id="turnstile-container" class="mt-2" data-sitekey="{{ cond (eq (urls.Parse .Site.BaseURL).Host "george.tsiokos.com") .Site.Params.turnstileSiteKey "3x00000000000000000000FF" }}"></div>
    <p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">Comments are reviewed before publishing.</p>
    <div class="comment-actions">
      <button type="submit" id="comment-submit" class="btn btn-primary">Submit</button>
      <button type="button" id="comment-cancel" class="btn btn-secondary">Cancel</button>
    </div>
    <div id="comment-status" role="alert" aria-live="polite" class="mt-3 text-sm"></div>
  </form>
</dialog>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=onTurnstileLoad" async defer></script>
<script>
(function () {
  var dialog = document.getElementById('comment-dialog');
  var openBtn = document.getElementById('commentButton');
  var cancelBtn = document.getElementById('comment-cancel');
  var form = document.getElementById('comment-form');
  var statusEl = document.getElementById('comment-status');
  var submitBtn = document.getElementById('comment-submit');
  var tsContainer = document.getElementById('turnstile-container');
  var indicator = document.getElementById('comment-draft-indicator');
  var textarea = form.querySelector('[name="content"]');
  var previewEl = document.getElementById('comment-preview');
  var tsWidgetId = null;
  var dialogOpen = false;
  var isClosing = false;
  var draftTimer;
  var previewReady = false;
  var previewLoading = false;
  var submitting = false;
  var commentId = null;
  var autoCloseTimer = null;
  var RATE_LIMIT_COOLDOWN_S = 60;

  function tsReady() { return !!(window.turnstile && tsWidgetId !== null); }

  var slug = dialog.dataset.source.replace(/[^a-z0-9]/gi, '-');
  var draftKey = 'comment-draft-' + slug;

  function getCookie(name) {
    var m = document.cookie.match('(?:^|; )' + encodeURIComponent(name) + '=([^;]*)');
    return m ? decodeURIComponent(m[1]) : '';
  }

  function setCookie(name, value, maxAge) {
    document.cookie = encodeURIComponent(name) + '=' + encodeURIComponent(value)
      + ';max-age=' + maxAge + ';path=/;SameSite=Lax';
  }

  function clearCookie(name) {
    document.cookie = encodeURIComponent(name) + '=;max-age=0;path=/;SameSite=Lax';
  }

  function loadPreviewLibs(cb) {
    if (previewReady) { cb(); return; }
    if (previewLoading) return;
    previewLoading = true;
    var loaded = 0, errors = 0;
    function onLoad() { if (++loaded === 2) { previewReady = true; previewLoading = false; cb(); } }
    function onError() {
      errors++;
      if (loaded + errors === 2) {
        previewLoading = false;
        console.warn('[comment] Preview library failed to load — preview unavailable');
        previewEl.textContent = 'Preview unavailable.';
        cb();
      }
    }
    var s1 = document.createElement('script');
    s1.src = '/lib/marked/marked.min.js';
    s1.integrity = 'sha384-948ahk4ZmxYVYOc+rxN1H2gM1EJ2Duhp7uHtZ4WSLkV4Vtx5MUqnV+l7u9B+jFv+';
    s1.onload = onLoad;
    s1.onerror = onError;
    var s2 = document.createElement('script');
    s2.src = '/lib/dompurify/purify.min.js';
    s2.integrity = 'sha384-n+fdIzzh3IT8fOJ5acZ8un+iQOuq+GaqxJ+79nWOVIanmOiQ5o6G6tg81hOeJw4R';
    s2.onload = onLoad;
    s2.onerror = onError;
    document.head.appendChild(s1);
    document.head.appendChild(s2);
  }

  function updatePreview() {
    if (!window.marked || !window.DOMPurify) return;
    var parsed = window.marked.parse(textarea.value);
    if (typeof parsed !== 'string') return;
    var sanitized = window.DOMPurify.sanitize(parsed);
    previewEl.textContent = '';
    try {
      previewEl.appendChild(document.createRange().createContextualFragment(sanitized));
    } catch (e) {
      console.warn('[comment] createContextualFragment failed, using innerHTML:', e);
      previewEl.innerHTML = sanitized;
    }
  }

  var toolbar = document.querySelector('.comment-toolbar');
  if (toolbar) {
    toolbar.addEventListener('click', function (e) {
      var btn = e.target.closest('[data-format]');
      if (!btn) return;
      var fmt = btn.dataset.format;
      var start = textarea.selectionStart;
      var end = textarea.selectionEnd;
      var selected = textarea.value.substring(start, end);
      var before = textarea.value.substring(0, start);
      var after = textarea.value.substring(end);
      var replacement, selStart, selEnd;
      var ph;

      switch (fmt) {
        case 'bold':
          ph = 'bold text';
          replacement = '**' + (selected || ph) + '**';
          selStart = selected ? start + replacement.length : start + 2;
          selEnd = selected ? selStart : selStart + (selected || ph).length;
          break;
        case 'italic':
          ph = 'italic text';
          replacement = '*' + (selected || ph) + '*';
          selStart = selected ? start + replacement.length : start + 1;
          selEnd = selected ? selStart : selStart + (selected || ph).length;
          break;
        case 'code':
          ph = 'code';
          replacement = '`' + (selected || ph) + '`';
          selStart = selected ? start + replacement.length : start + 1;
          selEnd = selected ? selStart : selStart + (selected || ph).length;
          break;
        case 'link':
          if (selected) {
            replacement = '[' + selected + '](url)';
            selStart = start + selected.length + 3;
            selEnd = selStart + 3;
          } else {
            ph = 'link text';
            replacement = '[' + ph + '](url)';
            selStart = start + 1;
            selEnd = selStart + ph.length;
          }
          break;
        default:
          return;
      }

      try {
        textarea.value = before + replacement + after;
        textarea.focus();
        textarea.setSelectionRange(selStart, selEnd);
        textarea.dispatchEvent(new Event('input', { bubbles: true }));
      } catch (e) {
        console.warn('[comment] Toolbar formatting failed for "' + fmt + '":', e);
      }
    });
  }

  if (getCookie(draftKey)) indicator.textContent = ' (draft pending)';

  function loadSaved() {
    try {
      var saved = JSON.parse(localStorage.getItem('comment-author') || 'null');
      if (saved) {
        if (saved.name) form.querySelector('[name="name"]').value = saved.name;
        if (saved.email) form.querySelector('[name="email"]').value = saved.email;
        if (saved.url) form.querySelector('[name="url"]').value = saved.url;
      }
    } catch (e) { console.warn('[comment] Failed to read author from localStorage:', e); }
    var draft = getCookie(draftKey);
    if (draft) { textarea.value = draft; updatePreview(); }
  }

  textarea.addEventListener('input', function () {
    updatePreview();
    clearTimeout(draftTimer);
    draftTimer = setTimeout(function () {
      var val = textarea.value;
      if (val.trim()) {
        setCookie(draftKey, val, 604800);
        indicator.textContent = ' (draft pending)';
      } else {
        clearCookie(draftKey);
        indicator.textContent = '';
      }
    }, 500);
  });

  function validateForm() {
    var name = form.querySelector('[name="name"]').value.trim();
    var email = form.querySelector('[name="email"]').value.trim();
    var url = form.querySelector('[name="url"]').value.trim();
    var content = textarea.value.trim();

    if (!name) return 'Name is required.';
    if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Please enter a valid email address.';
    if (url && !/^https?:\/\/.+/.test(url)) return 'Website must start with http:// or https://.';
    if (!content) return 'Comment is required.';
    return null;
  }

  function renderTurnstile() {
    tsWidgetId = window.turnstile.render(tsContainer, {
      sitekey: tsContainer.dataset.sitekey,
      'error-callback': function () {
        tsWidgetId = null;
        console.warn('[comment] Turnstile error-callback fired');
        setStatus('Security widget failed to load. Please refresh and try again.', 'error');
      },
      'expired-callback': function () {
        console.warn('[comment] Turnstile expired-callback fired');
        setStatus('Security check expired. Please complete it again.', 'error');
        if (tsReady()) window.turnstile.reset(tsWidgetId);
      },
    });
  }

  window.onTurnstileLoad = function () {
    if (dialogOpen && tsWidgetId === null) renderTurnstile();
  };

  openBtn.addEventListener('click', function () {
    commentId = crypto.randomUUID();
    submitting = false;
    submitBtn.disabled = false;
    dialog.showModal();
    dialogOpen = true;
    loadSaved();
    var nameField = form.querySelector('[name="name"]');
    if (nameField && nameField.value.trim()) {
      textarea.focus();
    } else if (nameField) {
      nameField.focus();
    }
    if (!tsReady() && window.turnstile) renderTurnstile();
    loadPreviewLibs(updatePreview);
  });

  function resetDialogState() { isClosing = false; dialogOpen = false; }

  cancelBtn.addEventListener('click', function () { closeDialogAnimated(); });
  dialog.addEventListener('cancel', function (e) {
    e.preventDefault();
    try {
      closeDialogAnimated();
    } catch (err) {
      console.error('[comment] closeDialogAnimated failed in cancel handler:', err);
      resetDialogState();
      try { dialog.close(); } catch (e) { console.error('[comment] Fallback dialog.close() failed:', e); resetDialogState(); }
    }
  });
  dialog.addEventListener('click', function (e) { if (e.target === dialog) closeDialogAnimated(); });
  dialog.addEventListener('close', function () {
    var closeName = form.querySelector('[name="name"]').value.trim();
    if (closeName) {
      try {
        localStorage.setItem('comment-author', JSON.stringify({
          name: closeName,
          email: form.querySelector('[name="email"]').value.trim(),
          url: form.querySelector('[name="url"]').value.trim(),
        }));
      } catch (e) { console.warn('[comment] Failed to save author to localStorage:', e); }
    }
    if (!textarea.value.trim()) {
      clearCookie(draftKey);
      indicator.textContent = '';
    }
    if (autoCloseTimer) { clearTimeout(autoCloseTimer); autoCloseTimer = null; }
    resetDialogState();
    statusEl.textContent = '';
    statusEl.className = 'mt-3 text-sm';
  });

  function closeDialogAnimated() {
    if (isClosing || !dialogOpen) return;
    isClosing = true;
    dialog.classList.add('dialog-closing');
    var closed = false;
    function doClose() {
      if (closed) return;
      closed = true;
      dialog.removeEventListener('animationend', onEnd);
      dialog.classList.remove('dialog-closing');
      if (autoCloseTimer) { clearTimeout(autoCloseTimer); autoCloseTimer = null; }
      try { dialog.close(); } catch (e) { console.warn('[comment] dialog.close() failed:', e); }
    }
    function onEnd(e) {
      if (e.target !== dialog) return;
      doClose();
    }
    dialog.addEventListener('animationend', onEnd);
    setTimeout(doClose, 200);
  }

  form.addEventListener('submit', async function (e) {
    e.preventDefault();

    if (submitting) return;

    var err = validateForm();
    if (err) { setStatus(err, 'error'); return; }

    var token = tsReady() ? window.turnstile.getResponse(tsWidgetId) : null;
    if (!token) {
      var tsMsg = window.turnstile
        ? 'Please complete the security check.'
        : 'Security widget failed to load. Please disable ad blockers and refresh.';
      setStatus(tsMsg, 'error');
      return;
    }

    submitting = true;
    submitBtn.disabled = true;
    setStatus('Submitting\u2026', '');

    var name = form.querySelector('[name="name"]').value.trim();
    var email = form.querySelector('[name="email"]').value.trim();
    var url = form.querySelector('[name="url"]').value.trim();
    var content = textarea.value.trim();

    try {
      var res = await fetch('/api/comment', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: name,
          email: email,
          url: url,
          content: content,
          sourceHash: dialog.dataset.source,
          turnstileToken: token,
          commentId: commentId,
        }),
      });
      if (res.status === 429) {
        var retryAfter = res.headers.get('Retry-After');
        var parsed = retryAfter ? parseInt(retryAfter, 10) : NaN;
        var waitSeconds = Number.isFinite(parsed) && parsed > 0 ? parsed : RATE_LIMIT_COOLDOWN_S;
        setStatus('Too many submissions. Please wait ' + waitSeconds + ' seconds before trying again.', 'error');
        if (tsReady()) window.turnstile.reset(tsWidgetId);
        submitting = false;
        submitBtn.disabled = false;
      } else {
        var data;
        try { data = await res.json(); } catch (e) {
          console.warn('[comment] Failed to parse response (HTTP ' + res.status + '):', e);
          if (res.ok) {
            setStatus('Your comment was submitted, but confirmation failed. If it doesn\'t appear, please try again.', 'error');
            submitting = false;
            submitBtn.disabled = false;
            if (tsReady()) window.turnstile.reset(tsWidgetId);
            return;
          }
          data = {};
        }
        if (res.ok) {
          try { localStorage.setItem('comment-author', JSON.stringify({ name: name, email: email, url: url })); } catch (e) { console.warn('[comment] Failed to save author to localStorage:', e); }
          clearCookie(draftKey);
          indicator.textContent = '';
          setStatus('Thank you! Your comment has been submitted for review.', 'success');
          form.reset();
          submitBtn.disabled = true;
          submitting = false;
          updatePreview();
          if (tsReady()) window.turnstile.reset(tsWidgetId);
          autoCloseTimer = setTimeout(closeDialogAnimated, 1500);
        } else {
          setStatus(data.error || (res.status >= 500 ? 'Server error. Please try again in a moment.' : 'Something went wrong. Please try again.'), 'error');
          if (tsReady()) window.turnstile.reset(tsWidgetId);
          submitting = false;
          submitBtn.disabled = false;
        }
      }
    } catch (err) {
      var errorId = Math.random().toString(36).substring(7);
      console.error('[comment] Submit failed (ID: ' + errorId + '):', err);
      setStatus('Network error (code: ' + errorId + '). Please try again.', 'error');
      submitting = false;
      submitBtn.disabled = false;
    }
  });

  function setStatus(msg, type) {
    statusEl.textContent = msg;
    statusEl.className = 'mt-3 text-sm' + (type ? ' comment-status-' + type : '');
  }
}());
</script>

Leave a comment

Preview

Comments are reviewed before publishing.