# 基于 WebDAV 的轻量说说/微语管理工具


一个基于 **WebDAV** 协议的极简微博客管理工具（Whispers Manager）。通过一个网页界面，在支持 WebDAV 的远程存储上发布、编辑、删除并管理自己的短篇文字内容（whispers）。

<!--more-->

通过 Alist 等网盘挂载工具，挂载 S3 服务，实现此网页工具读写微语数据，S3 服务搭配 CDN，实现博客等应用数据获取。

这个 HTML 是一个 **无后端、纯前端的微博客客户端**，它将 WebDAV 作为存储层，实现了完整的短内容发布与管理功能，尤其适合部署在支持 WebDAV 的个人云盘（如 NextCloud、OwnCloud、坚果云 WebDAV 等）或任何可 PUT/GET JSON 的 HTTP 服务上。切分文件功能让它具备一定程度的文章分页或备份归档能力。

同时，可以修改源码，将完整的 JSON 存储到 Alist 本地挂载的文件夹中。仅将分页公开。

其性能可能会有瓶颈，并非一劳永逸的完美解决方案，但我想它可以满足大部分轻量级微博客需求。

![](https://cdn.ftls.xyz/images/2026/04/Screenshot_20260427_125545.jpg)


## 代码

代码是 AI 写的，用了上篇文章提到的 Water.css 。和 AI 自己写的 CSS 有些冲突。问题不大。


```html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Whispers · 微博客管理</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/water.css@2/out/dark.css">
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<style>
  :root {
    --accent: #7dd3fc;
    --accent2: #a78bfa;
    --danger: #f87171;
    --success: #34d399;
    --muted: #64748b;
    --border: #1e293b;
    --card-bg: #0f172a;
    --font-mono: 'JetBrains Mono', 'Fira Code', monospace;
  }

  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Noto+Serif+SC:wght@400;600&display=swap');

  * { box-sizing: border-box; }

  body {
    font-family: 'Noto Serif SC', serif;
    max-width: 860px;
    margin: 0 auto;
    padding: 2rem 1.5rem;
    background: #020817;
    min-height: 100vh;
    background-image:
      radial-gradient(ellipse 80% 50% at 50% -20%, rgba(120,119,198,0.12), transparent),
      radial-gradient(ellipse 60% 40% at 80% 80%, rgba(125,211,252,0.06), transparent);
  }

  h1 {
    font-family: var(--font-mono);
    font-size: 1.6rem;
    letter-spacing: -0.02em;
    color: var(--accent);
    margin-bottom: 0.2rem;
    text-shadow: 0 0 30px rgba(125,211,252,0.3);
  }

  .subtitle {
    color: var(--muted);
    font-size: 0.82rem;
    font-family: var(--font-mono);
    margin-bottom: 2rem;
  }

  /* Config Panel */
  .config-panel {
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 1.2rem 1.4rem;
    margin-bottom: 1.5rem;
  }

  .config-panel summary {
    font-family: var(--font-mono);
    font-size: 0.8rem;
    color: var(--muted);
    cursor: pointer;
    user-select: none;
    letter-spacing: 0.05em;
  }

  .config-row {
    display: grid;
    grid-template-columns: 1fr 1fr auto;
    gap: 0.6rem;
    margin-top: 1rem;
    align-items: end;
  }

  .config-row label { font-size: 0.75rem; color: var(--muted); }

  /* Status bar */
  .status-bar {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    padding: 0.5rem 0.8rem;
    border-radius: 5px;
    margin-bottom: 1.2rem;
    transition: all 0.3s;
  }

  .status-bar.info    { background: rgba(125,211,252,0.08); color: var(--accent); border: 1px solid rgba(125,211,252,0.2); }
  .status-bar.success { background: rgba(52,211,153,0.08); color: var(--success); border: 1px solid rgba(52,211,153,0.2); }
  .status-bar.error   { background: rgba(248,113,113,0.08); color: var(--danger); border: 1px solid rgba(248,113,113,0.2); }
  .status-bar.loading { background: rgba(167,139,250,0.08); color: var(--accent2); border: 1px solid rgba(167,139,250,0.2); }

  .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
  .dot.pulse { animation: pulse 1.2s ease-in-out infinite; }
  @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.3} }

  /* Compose */
  .compose-box {
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 1.2rem 1.4rem;
    margin-bottom: 1.5rem;
  }

  .compose-box h3 {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--muted);
    letter-spacing: 0.08em;
    margin: 0 0 0.8rem;
    text-transform: uppercase;
  }

  textarea {
    width: 100%;
    min-height: 100px;
    resize: vertical;
    font-family: var(--font-mono);
    font-size: 0.88rem;
    background: #060e1c;
    border: 1px solid var(--border);
    border-radius: 6px;
    color: #e2e8f0;
    padding: 0.7rem 0.9rem;
    transition: border-color 0.2s;
    line-height: 1.6;
  }

  textarea:focus { border-color: var(--accent); outline: none; }

  .preview-box {
    background: #060e1c;
    border: 1px dashed rgba(125,211,252,0.2);
    border-radius: 6px;
    padding: 0.7rem 0.9rem;
    font-size: 0.88rem;
    line-height: 1.7;
    color: #cbd5e1;
    min-height: 40px;
    margin-top: 0.6rem;
  }

  .preview-label {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    color: var(--muted);
    margin-bottom: 0.3rem;
  }

  .compose-actions {
    display: flex;
    gap: 0.6rem;
    margin-top: 0.8rem;
    flex-wrap: wrap;
  }

  /* Buttons */
  button {
    font-family: var(--font-mono);
    font-size: 0.78rem;
    padding: 0.45rem 1rem;
    border-radius: 5px;
    border: none;
    cursor: pointer;
    transition: all 0.15s;
    letter-spacing: 0.03em;
  }

  .btn-primary {
    background: rgba(125,211,252,0.15);
    color: var(--accent);
    border: 1px solid rgba(125,211,252,0.3);
  }
  .btn-primary:hover { background: rgba(125,211,252,0.25); }

  .btn-danger {
    background: rgba(248,113,113,0.1);
    color: var(--danger);
    border: 1px solid rgba(248,113,113,0.25);
  }
  .btn-danger:hover { background: rgba(248,113,113,0.2); }

  .btn-ghost {
    background: transparent;
    color: var(--muted);
    border: 1px solid var(--border);
  }
  .btn-ghost:hover { color: #94a3b8; border-color: #334155; }

  .btn-split {
    background: rgba(167,139,250,0.1);
    color: var(--accent2);
    border: 1px solid rgba(167,139,250,0.25);
  }
  .btn-split:hover { background: rgba(167,139,250,0.2); }

  /* Toolbar */
  .toolbar {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    margin-bottom: 1rem;
    flex-wrap: wrap;
  }

  .count-badge {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--muted);
    background: var(--card-bg);
    border: 1px solid var(--border);
    padding: 0.25rem 0.7rem;
    border-radius: 99px;
  }

  /* Whisper cards */
  .whisper-list { display: flex; flex-direction: column; gap: 0.8rem; }

  .whisper-card {
    background: var(--card-bg);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 1rem 1.2rem;
    transition: border-color 0.2s, transform 0.1s;
    animation: slideIn 0.25s ease;
  }

  @keyframes slideIn {
    from { opacity: 0; transform: translateY(-8px); }
    to   { opacity: 1; transform: translateY(0); }
  }

  .whisper-card:hover { border-color: #334155; }

  .whisper-card.editing { border-color: var(--accent2); }

  .whisper-header {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    margin-bottom: 0.6rem;
  }

  .whisper-time {
    font-family: var(--font-mono);
    font-size: 0.7rem;
    color: var(--muted);
    flex: 1;
  }

  .whisper-actions { display: flex; gap: 0.4rem; }

  .btn-icon {
    background: transparent;
    border: none;
    color: var(--muted);
    padding: 0.2rem 0.5rem;
    font-size: 0.78rem;
    border-radius: 4px;
    cursor: pointer;
    transition: color 0.15s, background 0.15s;
    font-family: var(--font-mono);
  }

  .btn-icon:hover { color: #e2e8f0; background: rgba(255,255,255,0.05); }
  .btn-icon.edit:hover { color: var(--accent); }
  .btn-icon.del:hover  { color: var(--danger); }
  .btn-icon.save:hover { color: var(--success); }

  .whisper-content {
    font-size: 0.9rem;
    line-height: 1.75;
    color: #cbd5e1;
  }

  .whisper-content p { margin: 0 0 0.5em; }
  .whisper-content p:last-child { margin-bottom: 0; }
  .whisper-content code {
    font-family: var(--font-mono);
    font-size: 0.82em;
    background: rgba(125,211,252,0.08);
    color: var(--accent);
    padding: 0.1em 0.4em;
    border-radius: 3px;
  }
  .whisper-content pre {
    background: #060e1c;
    border: 1px solid var(--border);
    border-radius: 5px;
    padding: 0.7rem 0.9rem;
    overflow-x: auto;
  }
  .whisper-content pre code { background: none; padding: 0; }
  .whisper-content strong { color: #e2e8f0; }
  .whisper-content em { color: #94a3b8; }
  .whisper-content a { color: var(--accent); }
  .whisper-content blockquote {
    border-left: 3px solid rgba(125,211,252,0.3);
    margin: 0.5em 0;
    padding: 0.3em 0 0.3em 0.8em;
    color: var(--muted);
  }

  .edit-textarea {
    width: 100%;
    min-height: 80px;
    resize: vertical;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    background: #060e1c;
    border: 1px solid var(--accent2);
    border-radius: 5px;
    color: #e2e8f0;
    padding: 0.6rem 0.8rem;
    line-height: 1.6;
  }

  /* Split dialog */
  .split-dialog {
    background: var(--card-bg);
    border: 1px solid rgba(167,139,250,0.3);
    border-radius: 8px;
    padding: 1.2rem 1.4rem;
    margin-bottom: 1.5rem;
  }

  .split-dialog h3 {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--accent2);
    letter-spacing: 0.08em;
    text-transform: uppercase;
    margin: 0 0 0.8rem;
  }

  .split-config {
    display: flex;
    align-items: end;
    gap: 0.8rem;
    flex-wrap: wrap;
  }

  .split-files-preview {
    margin-top: 0.8rem;
    display: flex;
    gap: 0.5rem;
    flex-wrap: wrap;
  }

  .file-chip {
    font-family: var(--font-mono);
    font-size: 0.72rem;
    padding: 0.2rem 0.7rem;
    border-radius: 99px;
    background: rgba(167,139,250,0.1);
    color: var(--accent2);
    border: 1px solid rgba(167,139,250,0.2);
  }

  .empty-state {
    text-align: center;
    color: var(--muted);
    font-family: var(--font-mono);
    font-size: 0.8rem;
    padding: 3rem 1rem;
    border: 1px dashed var(--border);
    border-radius: 8px;
  }

  input[type="number"], input[type="text"], input[type="url"], input[type="password"] {
    font-family: var(--font-mono);
    font-size: 0.82rem;
  }

  details > summary { list-style: none; }
  details > summary::-webkit-details-marker { display: none; }

  .divider {
    border: none;
    border-top: 1px solid var(--border);
    margin: 1.5rem 0;
  }
</style>
</head>
<body x-data="whispersApp()" x-init="init()">

  <h1>// whispers</h1>
  <p class="subtitle">microblog · WebDAV manager</p>

  <!-- Config -->
  <details class="config-panel">
    <summary>⚙ 连接配置</summary>
    <div class="config-row">
      <div>
        <label>WebDAV 地址（或 API 基础 URL）</label>
        <input type="url" x-model="config.baseUrl" placeholder="https://dav.example.com" style="width:100%">
      </div>
      <div>
        <label>用户名</label>
        <input type="text" x-model="config.username" placeholder="username" style="width:100%">
      </div>
      <div>
        <label>密码 / Token</label>
        <input type="password" x-model="config.password" placeholder="••••••" style="width:100%">
      </div>
    </div>
    <div style="margin-top:0.8rem; display:flex; gap:0.6rem; align-items:center;">
      <div style="flex:1">
        <label style="font-size:0.75rem;color:var(--muted)">文件路径</label>
        <input type="text" x-model="config.filePath" style="width:100%">
      </div>
      <div style="padding-top:1.2rem">
        <button class="btn-primary" @click="loadWhispers()">连接并加载</button>
      </div>
    </div>
    <p style="font-size:0.72rem;color:var(--muted);margin-top:0.6rem;font-family:var(--font-mono)">
      ▸ 未填写凭据则以匿名方式请求 · 切分文件会将新文件写入同一目录
    </p>
  </details>

  <!-- Status -->
  <div class="status-bar" :class="status.type" x-show="status.msg">
    <span class="dot" :class="status.type === 'loading' ? 'pulse' : ''"></span>
    <span x-text="status.msg"></span>
  </div>

  <!-- Compose -->
  <div class="compose-box">
    <h3>✦ 写一条 Whisper</h3>
    <textarea
      x-model="draft"
      @input="previewHtml = renderMarkdown(draft)"
      placeholder="支持 Markdown：**粗体** *斜体* `代码` > 引用 # 标题 [链接](url) ..."
    ></textarea>
    <div x-show="draft.trim()">
      <p class="preview-label">预览</p>
      <div class="preview-box whisper-content" x-html="previewHtml"></div>
    </div>
    <div class="compose-actions">
      <button class="btn-primary" @click="addWhisper()" :disabled="!draft.trim()">+ 发布</button>
      <button class="btn-ghost" @click="draft=''; previewHtml=''">清空</button>
      <button class="btn-primary" @click="saveWhispers()" :disabled="whispers.length === 0" style="margin-left:auto">
        ↑ 保存到 WebDAV
      </button>
      <button class="btn-primary" @click="saveWhispers();splitWhispers();" style="margin-left:auto">
        ↑ 保存主文件并切分
      </button>
    </div>
  </div>

  <!-- Split tool -->
  <details>
    <summary style="font-family:var(--font-mono);font-size:0.75rem;color:var(--muted);cursor:pointer;margin-bottom:0.8rem;letter-spacing:0.05em">
      ✂ 切分文件工具
    </summary>
    <div class="split-dialog">
      <h3>切分 whispers.json</h3>
      <div class="split-config">
        <div>
          <label style="font-size:0.75rem;color:var(--muted)">每个文件最多条数</label>
          <input type="number" x-model.number="splitSize" min="1" style="width:100px">
        </div>
        <button class="btn-split" @click="splitWhispers()">执行切分</button>
      </div>
      <div class="split-files-preview" x-show="splitPreview.length > 0">
        <template x-for="f in splitPreview" :key="f">
          <span class="file-chip" x-text="f"></span>
        </template>
      </div>
      <p style="font-size:0.72rem;color:var(--muted);margin-top:0.6rem;font-family:var(--font-mono)" x-show="splitResult">
        <span x-text="splitResult"></span>
      </p>
    </div>
  </details>

  <hr class="divider">

  <!-- List toolbar -->
  <div class="toolbar">
    <button class="btn-ghost" @click="loadWhispers()">↻ 刷新</button>
    <input type="text" x-model="searchQ" placeholder="搜索…" style="flex:1;min-width:140px;max-width:260px;font-size:0.82rem">
    <span class="count-badge" x-text="filtered.length + ' / ' + whispers.length + ' 条'"></span>
  </div>

  <!-- List -->
  <div class="whisper-list">
    <template x-if="filtered.length === 0">
      <div class="empty-state">
        <div style="font-size:1.8rem;margin-bottom:0.5rem">◌</div>
        <span x-text="whispers.length === 0 ? '暂无内容 · 请先连接并加载' : '没有匹配的内容'"></span>
      </div>
    </template>

    <template x-for="(item, idx) in filtered" :key="item.created_at + idx">
      <div class="whisper-card" :class="item._editing ? 'editing' : ''">
        <div class="whisper-header">
          <span class="whisper-time" x-text="formatTime(item.created_at)"></span>
          <div class="whisper-actions">
            <template x-if="!item._editing">
              <button class="btn-icon edit" @click="startEdit(item)" title="编辑">✎</button>
            </template>
            <template x-if="item._editing">
              <button class="btn-icon save" @click="saveEdit(item)" title="保存">✓</button>
            </template>
            <button class="btn-icon del" @click="deleteWhisper(item)" title="删除">✕</button>
          </div>
        </div>

        <template x-if="!item._editing">
          <div class="whisper-content" x-html="renderMarkdown(item.content)"></div>
        </template>
        <template x-if="item._editing">
          <div>
            <textarea class="edit-textarea" x-model="item._draft"></textarea>
            <div style="display:flex;gap:0.5rem;margin-top:0.5rem">
              <button class="btn-primary" style="font-size:0.75rem" @click="saveEdit(item)">保存</button>
              <button class="btn-ghost" style="font-size:0.75rem" @click="cancelEdit(item)">取消</button>
            </div>
          </div>
        </template>
      </div>
    </template>
  </div>

<script>
function whispersApp() {
  return {
    config: {
      baseUrl: '',
      username: '',
      password: '',
      filePath: 'microblog/whispers.json',
    },
    whispers: [],
    draft: '',
    previewHtml: '',
    searchQ: '',
    status: { msg: '', type: 'info' },
    splitSize: 20,
    splitPreview: [],
    splitResult: '',

    get filtered() {
      if (!this.searchQ.trim()) return [...this.whispers].reverse();
      const q = this.searchQ.toLowerCase();
      return [...this.whispers].reverse().filter(w =>
        w.content.toLowerCase().includes(q)
      );
    },

    init() {
      const saved = localStorage.getItem('whispers_config');
      if (saved) {
        try { Object.assign(this.config, JSON.parse(saved)); } catch(e) {}
      }
      this.loadWhispers();
    },

    saveConfig() {
      localStorage.setItem('whispers_config', JSON.stringify(this.config));
    },

    setStatus(msg, type = 'info') {
      this.status = { msg, type };
    },

    buildHeaders() {
      const headers = { 'Content-Type': 'application/json' };
      if (this.config.username || this.config.password) {
        const cred = btoa(`${this.config.username}:${this.config.password}`);
        headers['Authorization'] = `Basic ${cred}`;
      }
      return headers;
    },

    fileUrl(path) {
      const base = this.config.baseUrl.replace(/\/$/, '');
      return `${base}/${path}`;
    },

    async loadWhispers() {
      if (!this.config.baseUrl) {
        this.setStatus('请先填写 WebDAV 地址', 'error');
        return;
      }
      this.saveConfig();
      this.setStatus('正在加载…', 'loading');
      try {
        const res = await fetch(this.fileUrl(this.config.filePath), {
          method: 'GET',
          headers: this.buildHeaders(),
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        const data = await res.json();
        this.whispers = Array.isArray(data) ? data : (data.whispers || []);
        this.setStatus(`已加载 ${this.whispers.length} 条记录`, 'success');
      } catch(e) {
        this.setStatus(`加载失败: ${e.message}`, 'error');
      }
    },

    async saveWhispers(path, data) {
      const filePath = path || this.config.filePath;
      const payload = data || this.whispers;
      if (!this.config.baseUrl) {
        this.setStatus('请先填写 WebDAV 地址', 'error');
        return false;
      }
      this.setStatus('正在保存…', 'loading');
      try {
        const res = await fetch(this.fileUrl(filePath), {
          method: 'PUT',
          headers: this.buildHeaders(),
          body: JSON.stringify(payload),
          // body: JSON.stringify(payload, null, 2),
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
        this.setStatus('已保存到 WebDAV ✓', 'success');
        return true;
      } catch(e) {
        this.setStatus(`保存失败: ${e.message}`, 'error');
        return false;
      }
    },

    addWhisper() {
      if (!this.draft.trim()) return;
      const item = {
        content: this.draft.trim(),
        created_at: new Date().toISOString(),
      };
      this.whispers.push(item);
      this.draft = '';
      this.previewHtml = '';
      this.setStatus('已添加，记得保存到 WebDAV', 'success');
    },

    startEdit(item) {
      item._draft = item.content;
      item._editing = true;
    },

    saveEdit(item) {
      if (item._draft !== undefined) {
        item.content = item._draft;
      }
      item._editing = false;
      this.setStatus('已修改，记得保存到 WebDAV', 'info');
    },

    cancelEdit(item) {
      item._editing = false;
      delete item._draft;
    },

    deleteWhisper(item) {
      if (!confirm('确认删除这条 Whisper？')) return;
      this.whispers = this.whispers.filter(w => w !== item);
      this.setStatus('已删除，记得保存到 WebDAV', 'info');
    },

    async splitWhispers() {
      if (this.whispers.length === 0) {
        this.setStatus('没有数据可切分', 'error');
        return;
      }
      const size = Math.max(1, this.splitSize);
      const chunks = [];
      const revWhispers = this.whispers.slice().reverse();
      console.log('Reversed whispers for splitting:', revWhispers);
      for (let i = 0; i < revWhispers.length; i += size) {
        // chunks.push(revWhispers.slice(i, i + size));
        const chunk = revWhispers.slice(i, i + size).map(item => ({
          content: item.content,
          created_at: item.created_at
        }));
        chunks.push(chunk);
      }

      // Build preview filenames
      const dir = this.config.filePath.replace(/[^/]+$/, '');
      const totalPages = Math.ceil(this.whispers.length / size);
      const files = chunks.map((_, i) => `${dir}whispers${i + 1}.json`);
      // const files = chunks.map((_, i) => `${dir}whispers${chunks.length - i}.json`);
      this.splitPreview = files;

      if (!this.config.baseUrl) {
        this.splitResult = `预览：将生成 ${files.join(', ')}（请先填写 WebDAV 地址以执行保存）`;
        return;
      }

      this.setStatus('正在切分并保存…', 'loading');
      let ok = 0;
      for (let i = 0; i < chunks.length; i++) {
        const success = await this.saveWhispers(files[i], { whispers: chunks[i] , total: this.whispers.length, page: i + 1, per_page: size, total_pages: totalPages });
        if (success) ok++;
      }
      this.splitResult = `已写入 ${ok}/${chunks.length} 个文件`;
      this.setStatus(`切分完成，写入 ${ok} 个文件`, 'success');
    },

    formatTime(iso) {
      if (!iso) return '';
      try {
        const d = new Date(iso);
        return d.toLocaleString('zh-CN', {
          year: 'numeric', month: '2-digit', day: '2-digit',
          hour: '2-digit', minute: '2-digit', second: '2-digit',
          hour12: false,
        });
      } catch(e) { return iso; }
    },

    renderMarkdown(text) {
      if (!text) return '';
      let html = text
        // Code blocks
        .replace(/```([\s\S]*?)```/g, (_, code) =>
          `<pre><code>${escHtml(code.trim())}</code></pre>`)
        // Inline code
        .replace(/`([^`]+)`/g, (_, c) => `<code>${escHtml(c)}</code>`)
        // Headings
        .replace(/^### (.+)$/gm, '<h3>$1</h3>')
        .replace(/^## (.+)$/gm, '<h2>$1</h2>')
        .replace(/^# (.+)$/gm, '<h1>$1</h1>')
        // Bold & italic
        .replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
        .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
        .replace(/\*(.+?)\*/g, '<em>$1</em>')
        .replace(/__(.+?)__/g, '<strong>$1</strong>')
        .replace(/_(.+?)_/g, '<em>$1</em>')
        // Strikethrough
        .replace(/~~(.+?)~~/g, '<del>$1</del>')
        // Blockquote
        .replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
        // Links
        .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>')
        // Horizontal rule
        .replace(/^---+$/gm, '<hr>')
        // Unordered list
        .replace(/^\s*[-*+] (.+)$/gm, '<li>$1</li>')
        // Ordered list
        .replace(/^\s*\d+\. (.+)$/gm, '<li>$1</li>')
        // Line breaks → paragraphs
        .split(/\n{2,}/)
        .map(p => {
          p = p.trim();
          if (!p) return '';
          if (/^<(h[1-6]|pre|blockquote|hr|ul|ol|li)/.test(p)) return p;
          if (p.startsWith('<li>')) return `<ul>${p}</ul>`;
          return `<p>${p.replace(/\n/g, '<br>')}</p>`;
        })
        .join('\n');

      return html;
    },
  };
}

function escHtml(str) {
  return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>
```