1/* global NexT, CONFIG */ 2 3(function(global) { 4 'use strict'; 5 6 const state = { 7 comments: [], 8 submitting: false 9 }; 10 11 const escapeHtml = value => String(value || '').replace(/[&<>"']/g, char => ({ 12 '&': '&', 13 '<': '<', 14 '>': '>', 15 '"': '"', 16 "'": ''' 17 }[char])); 18 19 const normalizeUrl = value => { 20 const text = String(value || '').trim(); 21 if (!text) return ''; 22 if (/^https?:\/\//i.test(text)) return text; 23 return `https://${text}`; 24 }; 25 26 const formatTime = value => { 27 if (!value) return ''; 28 try { 29 return new Intl.DateTimeFormat(navigator.language || 'zh-CN', { 30 year: 'numeric', 31 month: '2-digit', 32 day: '2-digit', 33 hour: '2-digit', 34 minute: '2-digit' 35 }).format(new Date(value)); 36 } catch (err) { 37 return value; 38 } 39 }; 40 41 const isSafeUrl = value => { 42 const text = String(value || '').trim(); 43 if (!text) return false; 44 if (/^(https?:|mailto:|\/|#)/i.test(text)) return true; 45 return !/^[a-z][a-z0-9+.-]*:/i.test(text); 46 }; 47 48 const renderInlineMarkdown = value => { 49 const tokens = []; 50 const stash = html => { 51 const token = `\u0000MLINE${tokens.length}\u0000`; 52 tokens.push(html); 53 return token; 54 }; 55 56 let text = String(value || '') 57 .replace(/`([^`\n]+)`/g, (match, code) => stash(`<code>${escapeHtml(code)}</code>`)) 58 .replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, (match, label, href) => { 59 if (!isSafeUrl(href)) return escapeHtml(label); 60 const safeHref = normalizeUrl(href); 61 return stash(`<a href="${escapeHtml(safeHref)}" target="_blank" rel="noopener nofollow">${escapeHtml(label)}</a>`); 62 }); 63 64 text = escapeHtml(text) 65 .replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>') 66 .replace(/(^|[^\*])\*([^*\n]+)\*/g, '$1<em>$2</em>'); 67 68 tokens.forEach((html, index) => { 69 text = text.replace(new RegExp(`\\u0000MLINE${index}\\u0000`, 'g'), html); 70 }); 71 return text; 72 }; 73 74 const renderMarkdown = value => { 75 const text = String(value || '').replace(/\r\n?/g, '\n').trim(); 76 if (!text) return ''; 77 78 return text 79 .split(/\n{2,}/) 80 .map(block => `<p>${block.split('\n').map(renderInlineMarkdown).join('<br>')}</p>`) 81 .join(''); 82 }; 83 84 const markdownFormats = { 85 bold: { 86 sample: 'ç²ä½', 87 wrap: value => `**${value}**` 88 }, 89 italic: { 90 sample: 'æä½', 91 wrap: value => `*${value}*` 92 }, 93 code: { 94 sample: 'code', 95 wrap: value => `\`${value}\`` 96 }, 97 link: { 98 sample: '龿¥', 99 wrap: value => `[${value}](https://example.com)` 100 } 101 }; 102 103 const emojis = ['ð', 'ð', 'ð', 'ð', 'ð', 'ð', 'ð', 'â¤ï¸', 'ð®', 'ð¢', 'ð¡', 'ð']; 104 105 const storageKey = 'mline.commenter'; 106 107 const getProfile = () => { 108 try { 109 return JSON.parse(localStorage.getItem(storageKey) || '{}'); 110 } catch (err) { 111 return {}; 112 } 113 }; 114 115 const saveProfile = profile => { 116 localStorage.setItem(storageKey, JSON.stringify({ 117 nick: profile.nick, 118 mail: profile.mail, 119 link: profile.link 120 })); 121 }; 122 123 const apiUrl = (path, params = {}) => { 124 const configuredBase = location.hostname === 'localhost' || location.hostname === '127.0.0.1' 125 ? (CONFIG.mline.local_api_base || CONFIG.mline.api_base) 126 : CONFIG.mline.api_base; 127 const base = (configuredBase || '').replace(/\/+$/, ''); 128 const url = new URL(`${base}${path}`, location.origin); 129 Object.entries(params).forEach(([key, value]) => { 130 if (Array.isArray(value)) { 131 value.forEach(item => url.searchParams.append(key, item)); 132 } else if (value !== undefined && value !== null && value !== '') { 133 url.searchParams.set(key, value); 134 } 135 }); 136 return url.toString(); 137 }; 138 139 const request = async (path, options = {}) => { 140 const response = await fetch(apiUrl(path, options.query), { 141 method: options.method || 'GET', 142 headers: { 143 'Content-Type': 'application/json' 144 }, 145 body: options.body ? JSON.stringify(options.body) : undefined 146 }); 147 const data = await response.json().catch(() => ({})); 148 if (!response.ok) { 149 throw new Error(data.message || data.error || `HTTP ${response.status}`); 150 } 151 return data; 152 }; 153 154 const renderComment = comment => { 155 const link = normalizeUrl(comment.link); 156 const author = link 157 ? `<a href="${escapeHtml(link)}" target="_blank" rel="noopener nofollow">${escapeHtml(comment.nick)}</a>` 158 : escapeHtml(comment.nick); 159 const avatar = comment.mail_hash 160 ? `<img class="mline-avatar" src="https://www.gravatar.com/avatar/${comment.mail_hash}?d=mp&s=80" alt="">` 161 : '<div class="mline-avatar mline-avatar-fallback"></div>'; 162 163 return ` 164 <article class="mline-comment" id="mline-comment-${comment.id}"> 165 ${avatar} 166 <div class="mline-comment-body"> 167 <div class="mline-comment-meta">
168 <span class="mline-comment-author">${author}</span> 169 <time datetime="${escapeHtml(comment.created_at)}">${escapeHtml(formatTime(comment.created_at))}</time> 170 </div> 171 <div class="mline-comment-content">${renderMarkdown(comment.comment)}</div> 172 </div> 173 </article> 174 `; 175 }; 176 177 const render = container => { 178 const profile = getProfile(); 179 const count = state.comments.length; 180 181 container.innerHTML = ` 182 <div class="mline-box"> 183 <form class="mline-form"> 184 <div class="mline-fields"> 185 <input name="nick" required maxlength="64" placeholder="${escapeHtml(CONFIG.mline.placeholder_nick || 'æµç§°')}" value="${escapeHtml(profile.nick)}"> 186 <input name="mail" type="email" maxlength="128" placeholder="${escapeHtml(CONFIG.mline.placeholder_mail || 'é®ç®±')}" value="${escapeHtml(profile.mail)}"> 187 <input name="link" maxlength="255" placeholder="${escapeHtml(CONFIG.mline.placeholder_link || 'ç½ç«')}" value="${escapeHtml(profile.link)}"> 188 </div> 189 <div class="mline-toolbar" aria-label="è¯è®ºç¼è¾å·¥å ·æ "> 190 <button type="button" data-mline-format="bold" title="ç²ä½">B</button> 191 <button type="button" data-mline-format="italic" title="æä½"><em>I</em></button> 192 <button type="button" data-mline-format="link" title="龿¥">ð</button> 193 <button type="button" data-mline-format="code" title="è¡å 代ç "></></button> 194 <button type="button" class="mline-emoji-toggle" aria-expanded="false">表æ </button> 195 <button type="button" class="mline-preview-toggle" aria-expanded="false">é¢è§</button> 196 </div> 197 <div class="mline-emoji-panel" hidden> 198 ${emojis.map(emoji => `<button type="button" data-mline-emoji="${escapeHtml(emoji)}">${escapeHtml(emoji)}</button>`).join('')} 199 </div> 200 <textarea name="comment" required maxlength="${Number(CONFIG.mline.max_comment_length || 2000)}" rows="5" placeholder="${escapeHtml(CONFIG.mline.placeholder_comment || 'åä¸ä½ çè¯è®º')}"></textarea> 201 <div class="mline-preview" hidden></div> 202 <div class="mline-actions"> 203 <span class="mline-status" aria-live="polite"></span> 204 <button type="submit">${escapeHtml(CONFIG.mline.submit_text || 'æäº¤è¯è®º')}</button> 205 </div> 206 </form> 207 <div class="mline-list-title">${count ? `${count} æ¡è¯è®º` : 'ææ è¯è®º'}</div> 208 <div class="mline-list">${state.comments.map(renderComment).join('')}</div> 209 </div> 210 `; 211 }; 212 213 const bind = container => { 214 const form = container.querySelector('.mline-form'); 215 const status = container.querySelector('.mline-status'); 216 const textarea = form.elements.comment; 217 const submitButton = form.querySelector('button[type="submit"]'); 218 const emojiPanel = form.querySelector('.mline-emoji-panel'); 219 const emojiToggle = form.querySelector('.mline-emoji-toggle'); 220 const preview = form.querySelector('.mline-preview'); 221 const previewToggle = form.querySelector('.mline-preview-toggle'); 222 223 const updatePreview = () => { 224 preview.innerHTML = renderMarkdown(textarea.value) || '<p class="mline-preview-empty">ææ é¢è§</p>'; 225 }; 226 227 const insertAtCursor = value => { 228 const start = typeof textarea.selectionStart === 'number' ? textarea.selectionStart : textarea.value.length; 229 const end = typeof textarea.selectionEnd === 'number' ? textarea.selectionEnd : textarea.value.length; 230 textarea.setRangeText(value, start, end, 'end'); 231 textarea.focus(); 232 updatePreview(); 233 }; 234 235 form.addEventListener('click', event => { 236 const button = event.target.closest('button'); 237 if (!button) return; 238 239 const emoji = button.getAttribute('data-mline-emoji'); 240 if (emoji) { 241 insertAtCursor(emoji); 242 return; 243 } 244 245 const format = button.getAttribute('data-mline-format'); 246 if (format && markdownFormats[format]) { 247 const selection = textarea.value.slice(textarea.selectionStart, textarea.selectionEnd); 248 insertAtCursor(markdownFormats[format].wrap(selection || markdownFormats[format].sample)); 249 return; 250 } 251 252 if (button === emojiToggle) { 253 const expanded = emojiPanel.hidden; 254 emojiPanel.hidden = !expanded; 255 emojiToggle.setAttribute('aria-expanded', String(expanded)); 256 return; 257 } 258 259 if (button === previewToggle) { 260 const expanded = preview.hidden; 261 preview.hidden = !expanded; 262 previewToggle.setAttribute('aria-expanded', String(expanded)); 263 if (expanded) updatePreview(); 264 } 265 }); 266 267 textarea.addEventListener('input', () => { 268 if (!preview.hidden) updatePreview(); 269 }); 270 271 form.addEventListener('submit', async event => { 272 event.preventDefault(); 273 if (state.submitting) return; 274 275 const formData = new FormData(form); 276 const payload = { 277 path: CONFIG.mline.path || location.pathname, 278 url: CONFIG.mline.url || location.href, 279 title: CONFIG.mline.title || document.title, 280 nick: String(formData.get('nick') || '').trim(), 281 mail: String(formData.get('mail') || '').trim(), 282 link: String(formData.get('link') || '').trim(), 283 comment: String(formData.get('comment') || '').trim() 284 }; 285 286 state.submitting = true; 287 status.textContent = CONFIG.mline.submitting_text || 'æäº¤ä¸...'; 288 submitButton.disabled = true; 289
290 try { 291 const data = await request('/comments', { 292 method: 'POST', 293 body: payload 294 }); 295 saveProfile(payload); 296 state.comments.unshift(data.comment); 297 form.comment.value = ''; 298 render(container); 299 bind(container); 300 } catch (err) { 301 status.textContent = err.message; 302 } finally { 303 state.submitting = false; 304 const button = form.querySelector('button[type="submit"]'); 305 if (button) button.disabled = false; 306 } 307 }); 308 }; 309 310 const boot = async () => { 311 if (!CONFIG.page.comments || !CONFIG.mline || !CONFIG.mline.api_base) return; 312 313 const container = document.querySelector('.mline-container'); 314 if (!container) return; 315 316 await NexT.utils.loadComments('.mline-container'); 317 container.innerHTML = '<div class="mline-loading">å è½½è¯è®ºä¸...</div>'; 318 319 try { 320 const health = await request('/health'); 321 if (!health.ok) { 322 throw new Error('MLine API is not healthy'); 323 } 324 const data = await request('/comments', { 325 query: { 326 path: CONFIG.mline.path || location.pathname 327 } 328 }); 329 state.comments = data.comments || []; 330 render(container); 331 bind(container); 332 } catch (err) { 333 container.innerHTML = `<div class="mline-error">${escapeHtml(CONFIG.mline.unavailable_text || 'MLine ææ¶ä¸å¯ç¨ï¼è¯·åæ¢å°å ¶ä»è¯è®ºæ¹å¼ã')}</div>`; 334 } 335 }; 336 337 global.MLine = { 338 renderMarkdown, 339 escapeHtml, 340 normalizeUrl 341 }; 342 343 document.addEventListener('page:loaded', boot); 344})(typeof window !== 'undefined' ? window : globalThis);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.