/** * Docs Comments Widget — Gitea Issues-backed comments for MkDocs pages * * Loads approved comments from the Express API proxy, supports: * - Anonymous comments (with moderation queue) * - Gitea OAuth2 login for instant comments * - Dark/light theme via MkDocs Material CSS vars * - SPA re-init via document$.subscribe() */ (function () { 'use strict'; // --- Config --- function getApiUrl() { // env-config.js sets window.PAYMENT_API_URL to the resolved API URL return window.PAYMENT_API_URL || 'http://localhost:4000'; } var API_BASE = ''; var SESSION_KEY = 'docs-comment-gitea-token'; var USER_KEY = 'docs-comment-gitea-user'; var STATE_KEY = 'docs-comment-oauth-state'; var RETURN_KEY = 'docs-comment-return-url'; // --- Helpers --- function escapeHtml(str) { var div = document.createElement('div'); div.appendChild(document.createTextNode(str)); return div.innerHTML; } /** * Minimal inline markdown: **bold**, *italic*, `code`, [text](url) */ function renderInlineMarkdown(text) { var html = escapeHtml(text); // Code (backticks) html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*(.+?)\*\*/g, '$1'); // Italic html = html.replace(/\*(.+?)\*/g, '$1'); // Links html = html.replace( /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1' ); // Newlines html = html.replace(/\n/g, '
'); return html; } function timeAgo(dateStr) { var now = Date.now(); var then = new Date(dateStr).getTime(); var diff = Math.floor((now - then) / 1000); if (diff < 60) return 'just now'; if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago'; return new Date(dateStr).toLocaleDateString(); } function getInitials(name) { return name .split(/\s+/) .map(function (w) { return w[0]; }) .join('') .toUpperCase() .slice(0, 2); } // --- Auth --- function getToken() { try { return sessionStorage.getItem(SESSION_KEY); } catch { return null; } } function getUser() { try { var data = sessionStorage.getItem(USER_KEY); return data ? JSON.parse(data) : null; } catch { return null; } } function setAuth(token, user) { try { sessionStorage.setItem(SESSION_KEY, token); sessionStorage.setItem(USER_KEY, JSON.stringify(user)); } catch { /* sessionStorage unavailable */ } } function clearAuth() { try { sessionStorage.removeItem(SESSION_KEY); sessionStorage.removeItem(USER_KEY); } catch { /* ignore */ } } // --- API --- function fetchComments(pagePath, page, callback) { var url = API_BASE + '/api/docs-comments/comments?pagePath=' + encodeURIComponent(pagePath) + '&page=' + (page || 1); fetch(url) .then(function (res) { if (!res.ok) throw new Error('HTTP ' + res.status); return res.json(); }) .then(function (data) { callback(null, data); }) .catch(function (err) { callback(err); }); } function postAnonymous(payload, callback) { fetch(API_BASE + '/api/docs-comments/comments/anonymous', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) .then(function (res) { if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); }); return res.json(); }) .then(function (data) { callback(null, data); }) .catch(function (err) { callback(err); }); } function postAuthenticated(payload, token, callback) { fetch(API_BASE + '/api/docs-comments/comments/authenticated', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Gitea-Token': token, }, body: JSON.stringify(payload), }) .then(function (res) { if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); }); return res.json(); }) .then(function (data) { callback(null, data); }) .catch(function (err) { callback(err); }); } function fetchOAuthConfig(callback) { fetch(API_BASE + '/api/docs-comments/oauth/config') .then(function (res) { return res.json(); }) .then(function (data) { callback(null, data); }) .catch(function (err) { callback(err); }); } function exchangeCode(code, redirectUri, callback) { fetch(API_BASE + '/api/docs-comments/oauth/exchange', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code: code, redirectUri: redirectUri }), }) .then(function (res) { if (!res.ok) throw new Error('OAuth exchange failed'); return res.json(); }) .then(function (data) { callback(null, data); }) .catch(function (err) { callback(err); }); } // --- Render --- function renderAvatar(comment) { if (comment.avatarUrl) { return ''; } return '' + escapeHtml(getInitials(comment.authorName)) + ''; } function renderComment(comment) { var fullDate = new Date(comment.createdAt).toLocaleString(); return ( '
' + '
' + renderAvatar(comment) + '' + escapeHtml(comment.authorName) + '' + (comment.isAnonymous ? 'Guest' : '') + '' + '
' + '
' + renderInlineMarkdown(comment.body) + '
' + '
' ); } function renderCommentList(data) { if (!data.comments || data.comments.length === 0) { return '

No comments yet. Be the first to share your thoughts!

'; } return data.comments.map(renderComment).join(''); } function renderForm(pagePath, oauthConfig) { var token = getToken(); var user = getUser(); if (token && user) { // Authenticated form return ( '
' + '
' + '' + 'Commenting as ' + escapeHtml(user.name) + '' + '' + '
' + '' + '
' + '' + '
' + '
' ); } // Anonymous form with optional OAuth login var loginBtn = ''; if (oauthConfig && oauthConfig.oauthEnabled) { loginBtn = ''; } return ( '
' + '
' + '' + '' + '
' + // Honeypot — hidden from humans '' + '' + '
' + '' + loginBtn + '
' + '

Guest comments are reviewed before appearing.

' + '
' ); } // --- Main Init --- function initWidget(container) { var pagePath = container.getAttribute('data-page-path') || ''; if (!pagePath) return; API_BASE = getApiUrl(); container.innerHTML = '
' + '

Comments

' + '
Loading comments...
' + '
' + '
'; var listEl = container.querySelector('.dc-comments-list'); var formContainer = container.querySelector('.dc-form-container'); var oauthConfig = null; // Load OAuth config + comments in parallel fetchOAuthConfig(function (err, config) { if (!err && config) oauthConfig = config; renderFormSection(); }); loadComments(1); function loadComments(page) { listEl.classList.add('dc-loading'); listEl.innerHTML = 'Loading comments...'; fetchComments(pagePath, page, function (err, data) { listEl.classList.remove('dc-loading'); if (err) { listEl.innerHTML = '

Comments unavailable.

'; return; } listEl.innerHTML = renderCommentList(data); }); } function renderFormSection() { formContainer.innerHTML = renderForm(pagePath, oauthConfig); bindFormEvents(); } function bindFormEvents() { // Anonymous submit var submitAnon = formContainer.querySelector('.dc-submit-anon'); if (submitAnon) { submitAnon.addEventListener('click', function () { var name = formContainer.querySelector('.dc-anon-name').value.trim(); var email = formContainer.querySelector('.dc-anon-email').value.trim(); var body = formContainer.querySelector('.dc-anon-body').value.trim(); var honeypot = formContainer.querySelector('.dc-honeypot').value; if (!name) { showFormError('Please enter your name.'); return; } if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } submitAnon.disabled = true; submitAnon.textContent = 'Posting...'; postAnonymous( { pagePath: pagePath, authorName: name, authorEmail: email || undefined, body: body, website: honeypot }, function (err) { submitAnon.disabled = false; submitAnon.textContent = 'Post as Guest'; if (err) { showFormError(err.message || 'Failed to post comment.'); return; } formContainer.querySelector('.dc-anon-body').value = ''; showFormSuccess('Your comment has been submitted for review.'); } ); }); } // Authenticated submit var submitAuth = formContainer.querySelector('.dc-submit-auth'); if (submitAuth) { submitAuth.addEventListener('click', function () { var body = formContainer.querySelector('.dc-auth-body').value.trim(); var token = getToken(); if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } if (!token) { showFormError('Session expired. Please sign in again.'); clearAuth(); renderFormSection(); return; } submitAuth.disabled = true; submitAuth.textContent = 'Posting...'; postAuthenticated( { pagePath: pagePath, body: body }, token, function (err) { submitAuth.disabled = false; submitAuth.textContent = 'Post Comment'; if (err) { if (err.message && err.message.indexOf('401') !== -1) { clearAuth(); renderFormSection(); showFormError('Session expired. Please sign in again.'); return; } showFormError(err.message || 'Failed to post comment.'); return; } formContainer.querySelector('.dc-auth-body').value = ''; loadComments(1); } ); }); } // OAuth login var loginBtn = formContainer.querySelector('.dc-login-btn'); if (loginBtn && oauthConfig && oauthConfig.oauthEnabled) { loginBtn.addEventListener('click', function () { var state = Math.random().toString(36).slice(2); try { sessionStorage.setItem(STATE_KEY, state); sessionStorage.setItem(RETURN_KEY, window.location.href); } catch { /* ignore */ } var redirectUri = window.location.origin + '/comments/callback/'; var url = oauthConfig.authorizeUrl + '?client_id=' + encodeURIComponent(oauthConfig.clientId) + '&redirect_uri=' + encodeURIComponent(redirectUri) + '&response_type=code' + '&state=' + encodeURIComponent(state); window.location.href = url; }); } // Logout var logoutBtn = formContainer.querySelector('.dc-logout-btn'); if (logoutBtn) { logoutBtn.addEventListener('click', function () { clearAuth(); renderFormSection(); }); } } function showFormError(msg) { removeFormMessages(); var el = document.createElement('p'); el.className = 'dc-form-message dc-form-message--error'; el.textContent = msg; formContainer.appendChild(el); setTimeout(function () { el.remove(); }, 5000); } function showFormSuccess(msg) { removeFormMessages(); var el = document.createElement('p'); el.className = 'dc-form-message dc-form-message--success'; el.textContent = msg; formContainer.appendChild(el); setTimeout(function () { el.remove(); }, 5000); } function removeFormMessages() { var msgs = formContainer.querySelectorAll('.dc-form-message'); for (var i = 0; i < msgs.length; i++) msgs[i].remove(); } } // --- OAuth Callback Handler --- function handleOAuthCallback() { var params = new URLSearchParams(window.location.search); var code = params.get('code'); var state = params.get('state'); if (!code) return; // Verify state var savedState; try { savedState = sessionStorage.getItem(STATE_KEY); } catch { /* ignore */ } if (savedState && state !== savedState) { console.warn('[DocsComments] OAuth state mismatch'); return; } API_BASE = getApiUrl(); var redirectUri = window.location.origin + '/comments/callback/'; exchangeCode(code, redirectUri, function (err, data) { if (err || !data || !data.accessToken) { console.error('[DocsComments] OAuth exchange failed:', err); return; } setAuth(data.accessToken, data.user); // Redirect back to original page var returnUrl; try { returnUrl = sessionStorage.getItem(RETURN_KEY); } catch { /* ignore */ } try { sessionStorage.removeItem(STATE_KEY); sessionStorage.removeItem(RETURN_KEY); } catch { /* ignore */ } window.location.href = returnUrl || '/'; }); } // --- SPA Init --- function init() { // Check if this is the OAuth callback page if (window.location.pathname.indexOf('/comments/callback') !== -1) { handleOAuthCallback(); return; } var container = document.getElementById('docs-comments'); if (container) { initWidget(container); } } // MkDocs Material SPA navigation support if (typeof document$ !== 'undefined') { document$.subscribe(function () { init(); }); } else { // Fallback for non-SPA or non-Material builds if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } } })();