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.
| Step | What happens |
|---|---|
| Form submission | A native <dialog> collects name, email (optional), website (optional), and Markdown content; generates a UUID for idempotent retries |
| Rate limiting | In-worker sliding window (5 per IP per 60 seconds) rejects excess submissions before any further processing |
| Bot protection | Cloudflare Turnstile token is verified server-side against Cloudflare’s API; submissions that fail the challenge are rejected with HTTP 403 |
| Path resolution | An 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 detection | Workers 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 PR | The function creates a branch, commits the comment into the post’s YAML front matter, and opens a PR for manual review |
| Publishing | Merging the PR triggers a Hugo build; comments.html iterates the comments: front matter array and renders each entry via markdownify |
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>