PageSourceSearch

https://geonmoo.github.io/js/mline.js

js geonmoo.github.io collected 2026-10-03 09:14:20 UTC 12,175 bytes, 344 lines download raw bytes

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    '&': '&amp;',
13    '<': '&lt;',
14    '>': '&gt;',
15    '"': '&quot;',
16    "'": '&#39;'
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="行内代码">&lt;/&gt;</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.