[{"data":1,"prerenderedAt":492},["ShallowReactive",2],{"content:\u002Fen\u002Ffiddling\u002Fvitepress-memos-component":3,"surround:\u002Fen\u002Ffiddling\u002Fvitepress-memos-component":481},{"id":4,"title":5,"authorship":6,"body":7,"categories":450,"date":452,"description":453,"draft":454,"extension":455,"image":456,"meta":457,"navigation":459,"path":460,"permalink":461,"published":461,"readingTime":462,"recommend":461,"references":461,"seo":467,"seoDescription":468,"seoTitle":461,"sitemap":469,"stem":472,"tags":473,"type":479,"__hash__":480},"content_en\u002Fposts\u002Ffiddling\u002Fvitepress-memos-component.md","Adding a Microblog to VitePress","human-only",{"type":8,"value":9,"toc":440},"minimark",[10,15,19,22,31,39,43,48,51,64,76,86,90,101,135,138,145,153,156,188,194,200,203,223,226,243,250,254,257,260,268,283,289,296,303,316,325,341,347,358,366,378,381,387,393,399,402,426,429,437],[11,12,14],"h3",{"id":13},"introduction","Introduction",[16,17,18],"p",{},"Many dynamic blogs have a microblog feature: essentially a special kind of post that takes advantage of dynamic publishing to let you write and publish immediately.",[16,20,21],{},"Static blogs first compile HTML locally or on a server and then deploy it, so they are less immediate. Writing a long post at a computer and deploying with Git is no great hassle. Having to get on a computer just to post a short thought is a bigger mental burden. Using Git on a phone is awkward too—not exactly elegant. I often end up deciding not to post at all.",[16,23,24,25,30],{},"So I built the frontend and backend for a microblog system, visible on this blog’s ",[26,27,29],"a",{"href":28},"\u002Fen\u002Fmemos","Memos"," page. The backend runs on Cloudflare Workers, with storage conveniently next door in our generous benefactor’s KV, and a simple admin page. Since the blog uses VitePress, the frontend is a Vue component embedded in a dedicated page.",[16,32,33,34],{},"You can see the frontend for yourself. Here is the backend management page:\n",[35,36],"img",{"alt":37,"src":38},"Memo management page","https:\u002F\u002Fblog-img.774352199.xyz\u002F2025\u002F8551751fe98e55c4159d28b9ff5b9473.png",[11,40,42],{"id":41},"backend-cloudflare-workers-kv","Backend: Cloudflare Workers + KV",[44,45,47],"h4",{"id":46},"overview","Overview",[16,49,50],{},"The backend provides:",[52,53,54,58,61],"ul",{},[55,56,57],"li",{},"Create, edit, and delete posts—the basics.",[55,59,60],{},"Authentication on the admin page and all write endpoints, for adequate security.",[55,62,63],{},"Live Markdown preview, powered by marked.",[16,65,66,67,71,72,75],{},"KV stores an ",[68,69,70],"code",{"code":70},"index"," key whose value is an array of UIDs indexing all posts. Each post is stored separately under its ",[68,73,74],{"code":74},"uid",", with a value formatted like this:",[77,78,84],"pre",{"className":79,"code":81,"language":82,"meta":83},[80],"language-js","{\n    \"uid\":\"唯一 id\",\n    \"createTime\":\"发布时间\",\n    \"content\":\"说说内容\",\n}\n","js","",[68,85,81],{"__ignoreMap":83},[44,87,89],{"id":88},"implementation","Implementation",[16,91,92,93,96,97,100],{},"First, create a Cloudflare KV namespace dedicated to the microblog. Go to ",[68,94,95],{"code":95},"Account Home → Storage & Databases → KV"," and click Create. The name does not matter much as long as you remember it; I simply used ",[68,98,99],{"code":99},"memos",".",[16,102,103,104,107,108,111,112,115,116,119,120,123,124,126,127,130,131,134],{},"Next, create a Cloudflare Worker for the application logic. Under ",[68,105,106],{"code":106},"Account Home → Compute (Workers) → Workers & Pages",", click Create. Again, the name is unimportant; mine is ",[68,109,110],{"code":110},"memos-api",". Open the Worker’s details, then add a binding under ",[68,113,114],{"code":114},"Settings → Bindings",". Choose ",[68,117,118],{"code":118},"KV namespace",", set the variable name to ",[68,121,122],{"code":122},"KV",", and select the namespace you just created, ",[68,125,99],{"code":99}," in my case. The code can now access that namespace directly through ",[68,128,129],{"code":129},"env.KV",". Finally, click ",[68,132,133],{"code":133},"Edit code"," in the upper-right toolbar.",[16,136,137],{},"Now, code time!",[16,139,140,141,144],{},"First, create ",[68,142,143],{"code":143},"index.html"," for the admin page’s HTML, CSS, and JavaScript.",[77,146,151],{"className":147,"code":149,"language":150,"meta":83},[148],"language-html","\u003C!DOCTYPE html>\n\u003Chtml>\n\n\u003Chead>\n    \u003Ctitle>Memos 管理\u003C\u002Ftitle>\n    \u003Cmeta charset=\"UTF-8\">\n    \u003Cmeta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n    \u003Clink rel=\"stylesheet\" href=\"https:\u002F\u002Fcdnjs.cloudflare.com\u002Fajax\u002Flibs\u002Ffont-awesome\u002F6.0.0\u002Fcss\u002Fall.min.css\">\n    \u003Cstyle>\n        :root {\n            --primary-color: #2c3e50;\n            --secondary-color: #34495e;\n            --accent-color: #3498db;\n            --background-color: #f5f6fa;\n            --text-color: #2c3e50;\n            --border-color: #dcdde1;\n            --hover-color: #f1f2f6;\n        }\n\n        * {\n            margin: 0;\n            padding: 0;\n            box-sizing: border-box;\n        }\n\n        body {\n            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;\n            background-color: var(--background-color);\n            color: var(--text-color);\n            line-height: 1.6;\n        }\n\n        #auth-panel {\n            position: fixed;\n            top: 0;\n            left: 0;\n            width: 100%;\n            height: 100%;\n            background: rgba(255, 255, 255, 0.95);\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            z-index: 1000;\n            backdrop-filter: blur(5px);\n        }\n\n        #auth-form {\n            background: white;\n            padding: 2rem;\n            border-radius: 10px;\n            box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);\n            width: 300px;\n        }\n\n        #auth-form input {\n            width: 100%;\n            padding: 0.8rem;\n            margin-bottom: 1rem;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            font-size: 1rem;\n        }\n\n        .container {\n            max-width: 1400px;\n            margin: 2rem auto;\n            padding: 0 1rem;\n            display: flex;\n            gap: 2rem;\n            height: calc(100vh - 4rem);\n        }\n\n        .memo-list {\n            width: 350px;\n            background: white;\n            border-radius: 10px;\n            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n            display: flex;\n            flex-direction: column;\n        }\n\n        .memo-list-header {\n            padding: 1rem;\n            border-bottom: 1px solid var(--border-color);\n            font-weight: 600;\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n\n        .memo-items {\n            flex: 1;\n            overflow-y: auto;\n            padding: 0.5rem;\n        }\n\n        .memo-item {\n            padding: 1rem;\n            border-radius: 8px;\n            margin-bottom: 0.5rem;\n            cursor: pointer;\n            transition: all 0.2s ease;\n            border: 1px solid var(--border-color);\n            height: auto;\n            \u002F* 移除固定高度 *\u002F\n            overflow: hidden;\n            position: relative;\n            display: flex;\n            flex-direction: column;\n            gap: 0.5rem;\n        }\n\n        .memo-item:hover {\n            background-color: var(--hover-color);\n            transform: translateY(-2px);\n        }\n\n        .memo-item.active {\n            border-color: var(--accent-color);\n            background-color: var(--hover-color);\n        }\n\n        .memo-detail {\n            flex: 1;\n            background: white;\n            border-radius: 10px;\n            box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);\n            display: flex;\n            flex-direction: column;\n        }\n\n        .memo-detail-header {\n            display: flex;\n            justify-content: space-between;\n            align-items: center;\n        }\n\n        .memo-item-header {\n            display: flex;\n            justify-content: space-between;\n            font-size: 0.8rem;\n            color: #666;\n            border-bottom: 1px solid var(--border-color);\n            padding-bottom: 0.5rem;\n        }\n\n        .memo-item-content {\n            font-size: 0.9rem;\n            line-height: 1.4;\n            max-height: 4.2em;\n            \u002F* 显示 3 行文本 *\u002F\n            overflow: hidden;\n            display: -webkit-box;\n            -webkit-line-clamp: 3;\n            -webkit-box-orient: vertical;\n        }\n\n        .memo-uid {\n            font-family: monospace;\n            color: var(--accent-color);\n        }\n\n        .memo-info {\n            font-size: 0.9rem;\n            color: #666;\n            margin-left: 10px;\n            margin-top: 10px;\n        }\n\n        .memo-content {\n            flex: 1;\n            display: flex;\n            flex-direction: column;\n            padding: 1rem;\n            gap: 1rem;\n        }\n\n        .memo-edit {\n            flex: 1;\n        }\n\n        .memo-edit textarea {\n            width: 100%;\n            height: 100%;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            padding: 1rem;\n            font-size: 1rem;\n            resize: vertical;\n            font-family: inherit;\n        }\n\n        .memo-preview {\n            flex: 1;\n            padding: 1rem;\n            border: 1px solid var(--border-color);\n            border-radius: 5px;\n            overflow-y: auto;\n            overflow-x: hidden;\n            background-color: var(--background-color);\n        }\n\n        .memo-preview img {\n            max-width: 100%;\n            max-height: 150px;\n            object-fit: contain;\n            display: block;\n            \u002F* 避免图片底部空隙 *\u002F\n            margin: 5px 0;\n        }\n\n        .memo-preview blockquote {\n            border-left: 2px solid #e2e2e3;\n            padding-left: 16px;\n            color: rgba(60, 60, 67, .78);\n        }\n\n        .memo-actions {\n            padding: 1rem;\n            border-top: 1px solid var(--border-color);\n            display: flex;\n            justify-content: flex-end;\n            gap: 1rem;\n        }\n\n        .pagination {\n            padding: 1rem;\n            display: flex;\n            justify-content: center;\n            align-items: center;\n            gap: 1rem;\n            border-top: 1px solid var(--border-color);\n        }\n\n        button {\n            padding: 0.5rem 1rem;\n            border: none;\n            border-radius: 5px;\n            cursor: pointer;\n            transition: all 0.2s ease;\n            font-size: 0.9rem;\n            background-color: var(--primary-color);\n            color: white;\n        }\n\n        button:hover {\n            opacity: 0.9;\n        }\n\n        button.secondary {\n            background-color: var(--secondary-color);\n        }\n\n        button.danger {\n            background-color: #e74c3c;\n        }\n\n        .create-btn {\n            padding: 0.5rem 1rem;\n            display: flex;\n            align-items: center;\n            gap: 0.5rem;\n            margin-right: 10px;\n            margin-top: 10px;\n        }\n\n        \u002F* Responsive Design *\u002F\n        @media (max-width: 768px) {\n            .container {\n                flex-direction: column;\n                height: auto;\n            }\n\n            .memo-list {\n                width: 100%;\n                height: 300px;\n            }\n\n            .memo-detail {\n                height: calc(100vh - 400px);\n            }\n\n            .memo-preview img {\n                max-height: 100px;\n            }\n        }\n\n        \u002F* Markdown Preview Styles *\u002F\n        .memo-preview h1,\n        .memo-preview h2,\n        .memo-preview h3 {\n            margin-top: 1rem;\n            margin-bottom: 0.5rem;\n        }\n\n        .memo-preview p {\n            margin-bottom: 1rem;\n        }\n\n        .memo-preview code {\n            background-color: #f8f9fa;\n            padding: 0.2rem 0.4rem;\n            border-radius: 3px;\n            font-family: monospace;\n        }\n\n        .memo-preview pre {\n            background-color: #f8f9fa;\n            padding: 1rem;\n            border-radius: 5px;\n            overflow-x: auto;\n        }\n\n        \u002F* Loading Spinner *\u002F\n        .loading {\n            display: inline-block;\n            width: 20px;\n            height: 20px;\n            border: 2px solid rgba(0, 0, 0, 0.1);\n            border-radius: 50%;\n            border-top-color: var(--accent-color);\n            animation: spin 1s ease-in-out infinite;\n        }\n\n        @keyframes spin {\n            to {\n                transform: rotate(360deg);\n            }\n        }\n    \u003C\u002Fstyle>\n\u003C\u002Fhead>\n\n\u003Cbody>\n    \u003Cdiv id=\"auth-panel\">\n        \u003Cform id=\"auth-form\">\n            \u003Ch2 style=\"margin-bottom: 1rem;\">Memos 管理\u003C\u002Fh2>\n            \u003Cinput type=\"password\" id=\"password\" placeholder=\"Enter password\" required>\n            \u003Cbutton type=\"submit\" style=\"width: 100%\">登录\u003C\u002Fbutton>\n        \u003C\u002Fform>\n    \u003C\u002Fdiv>\n\n    \u003Cdiv class=\"container\">\n        \u003Cdiv class=\"memo-list\">\n            \u003Cdiv class=\"memo-list-header\">\n                \u003Cspan>已发布\u003C\u002Fspan>\n                \u003Cspan id=\"memo-count\">\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-items\" id=\"memo-items\">\u003C\u002Fdiv>\n            \u003Cdiv class=\"pagination\">\n                \u003Cbutton onclick=\"prevPage()\" class=\"secondary\">\n                    \u003Ci class=\"fas fa-chevron-left\">\u003C\u002Fi>\n                \u003C\u002Fbutton>\n                \u003Cspan id=\"page-info\">\u003C\u002Fspan>\n                \u003Cbutton onclick=\"nextPage()\" class=\"secondary\">\n                    \u003Ci class=\"fas fa-chevron-right\">\u003C\u002Fi>\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n\n        \u003Cdiv class=\"memo-detail\">\n            \u003Cdiv class=\"memo-detail-header\">\n                \u003Cdiv class=\"memo-info\" id=\"memo-info\">新 Memo\u003C\u002Fdiv>\n                \u003Cbutton class=\"create-btn\" onclick=\"createMemo()\">\n                    \u003Ci class=\"fas fa-plus\">\u003C\u002Fi> 发布新 Memo\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-content\">\n                \u003Cdiv class=\"memo-edit\">\n                    \u003Ctextarea id=\"memo-content\" placeholder=\"Write your memo here...\">\u003C\u002Ftextarea>\n                \u003C\u002Fdiv>\n                \u003Cdiv class=\"memo-preview\" id=\"memo-preview\">\u003C\u002Fdiv>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-actions\">\n                \u003Cbutton onclick=\"saveMemo()\" id=\"save-btn\">\n                    \u003Ci class=\"fas fa-save\">\u003C\u002Fi> 保存\n                \u003C\u002Fbutton>\n                \u003Cbutton onclick=\"deleteMemo()\" class=\"danger\" id=\"delete-btn\">\n                    \u003Ci class=\"fas fa-trash\">\u003C\u002Fi> 删除\n                \u003C\u002Fbutton>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\n    \u003Cscript src=\"https:\u002F\u002Fcdnjs.cloudflare.com\u002Fajax\u002Flibs\u002Fmarked\u002F2.0.3\u002Fmarked.min.js\">\u003C\u002Fscript>\n    \u003C!-- JavaScript 代码与之前相同，但需要添加以下功能增强 -->\n    \u003Cscript>\n        let password = '';\n        let currentMemo = null;\n        let offset = 0;\n        const limit = 10;\n        let total = 0;\n        let currentPageMap = {};\n\n        \u002F\u002F Authentication\n        document.getElementById('auth-form').addEventListener('submit', async (e) => {\n            e.preventDefault();\n            password = document.getElementById('password').value;\n            try {\n                const response = await fetch('\u002Fapi\u002Fauth', {\n                    method: 'POST',\n                    headers: {\n                        'Content-Type': 'application\u002Fjson',\n                    },\n                    body: JSON.stringify({ password }),\n                });\n                if (response.ok) {\n                    document.getElementById('auth-panel').style.display = 'none';\n                    loadMemos();\n                } else {\n                    showNotification('密码错误', 'error');\n                }\n            } catch (error) {\n                showNotification('密码错误', 'error');\n            }\n        });\n\n        \u002F\u002F Load memos\n        async function loadMemos() {\n            try {\n                const response = await fetch(`\u002Fapi\u002Fmemos?offset=${offset}&limit=${limit}`);\n                const data = await response.json();\n                displayMemos(data.data);\n                currentPageMap = data.data.reduce((acc, item) => {\n                    acc[item.uid] = item;\n                    return acc;\n                }, {})\n                total = data.total;\n                updatePagination();\n                document.getElementById('memo-count').textContent = `${total} memos`;\n            } catch (error) {\n                showNotification('加载列表错误', 'error');\n            }\n        }\n\n        function displayMemos(memos) {\n            const container = document.getElementById('memo-items');\n            container.innerHTML = memos.map(memo => `\n        \u003Cdiv class=\"memo-item\" data-id=\"${memo.uid}\" onclick=\"selectMemo('${memo.uid}')\">\n            \u003Cdiv class=\"memo-item-header\">\n                \u003Cspan class=\"memo-uid\">${memo.uid.slice(0, 8)}...\u003C\u002Fspan>\n                \u003Cspan>${new Date(memo.createTime).toLocaleString()}\u003C\u002Fspan>\n            \u003C\u002Fdiv>\n            \u003Cdiv class=\"memo-item-content\">\n                ${escapeHtml(memo.content)}\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n    `).join('');\n        }\n\n        async function selectMemo(uid) {\n            try {\n                const memo = currentPageMap[uid];\n                currentMemo = memo;\n                displayMemoDetail(memo);\n\n                \u002F\u002F Update selected state\n                document.querySelectorAll('.memo-item').forEach(item => {\n                    item.classList.remove('active');\n                });\n                document.querySelector(`.memo-item[data-id=\"${uid}\"]`)?.classList.add('active');\n            } catch (error) {\n                showNotification('加载 Memo 错误', 'error');\n            }\n        }\n\n        function displayMemoDetail(memo) {\n            document.getElementById('memo-info').innerHTML = memo.uid;\n            document.getElementById('memo-content').value = memo.content;\n            updatePreview();\n        }\n\n        function updatePreview() {\n            const content = document.getElementById('memo-content').value;\n            document.getElementById('memo-preview').innerHTML = marked(content);\n        }\n\n        document.getElementById('memo-content').addEventListener('input', updatePreview);\n\n        async function saveMemo() {\n            const content = document.getElementById('memo-content').value;\n            if (!content.trim()) {\n                showNotification('Memo 内容不得为空', 'error');\n                return;\n            }\n\n            try {\n                showLoading(true);\n\n                if (currentMemo) {\n                    \u002F\u002F Update existing memo\n                    await fetch(`\u002Fapi\u002Fmemos\u002F${currentMemo.uid}`, {\n                        method: 'PUT',\n                        headers: {\n                            'Authorization': password,\n                            'Content-Type': 'application\u002Fjson'\n                        },\n                        body: JSON.stringify({ content })\n                    });\n                } else {\n                    \u002F\u002F Create new memo\n                    await fetch('\u002Fapi\u002Fmemos', {\n                        method: 'POST',\n                        headers: {\n                            'Authorization': password,\n                            'Content-Type': 'application\u002Fjson'\n                        },\n                        body: JSON.stringify({ content })\n                    });\n                }\n\n                showNotification('保存 Memo 成功');\n                loadMemos();\n            } catch (error) {\n                showNotification('保存 Memo 失败', 'error');\n            } finally {\n                showLoading(false);\n            }\n        }\n\n        async function deleteMemo() {\n            if (!currentMemo) return;\n\n            if (confirm('确定要删除这条 Memo 吗？')) {\n                try {\n                    showLoading(true);\n                    await fetch(`\u002Fapi\u002Fmemos\u002F${currentMemo.uid}`, {\n                        method: 'DELETE',\n                        headers: {\n                            'Authorization': password\n                        }\n                    });\n                    showNotification('删除 Memo 成功');\n                    loadMemos();\n                    clearMemoDetail();\n                } catch (error) {\n                    showNotification('删除 Memo 失败', 'error');\n                } finally {\n                    showLoading(false);\n                }\n            }\n        }\n\n        function createMemo() {\n            currentMemo = null;\n            clearMemoDetail();\n        }\n\n        function clearMemoDetail() {\n            document.getElementById('memo-info').innerHTML = '新 Memo';\n            document.getElementById('memo-content').value = '';\n            document.getElementById('memo-preview').innerHTML = '';\n        }\n\n        function prevPage() {\n            if (offset - limit >= 0) {\n                offset -= limit;\n                loadMemos();\n            }\n        }\n\n        function nextPage() {\n            if (offset + limit \u003C total) {\n                offset += limit;\n                loadMemos();\n            }\n        }\n\n        function updatePagination() {\n            const currentPage = Math.floor(offset \u002F limit) + 1;\n            const totalPages = Math.ceil(total \u002F limit);\n            document.getElementById('page-info').textContent =\n                `Page ${currentPage} of ${totalPages}`;\n        }\n\n        function showLoading(show) {\n            const saveBtn = document.getElementById('save-btn');\n            if (show) {\n                saveBtn.innerHTML = '\u003Cdiv class=\"loading\">\u003C\u002Fdiv> 保存中...';\n                saveBtn.disabled = true;\n            } else {\n                saveBtn.innerHTML = '\u003Ci class=\"fas fa-save\">\u003C\u002Fi> 保存';\n                saveBtn.disabled = false;\n            }\n        }\n\n        function showNotification(message, type = 'success') {\n            const notification = document.createElement('div');\n            notification.className = `notification ${type}`;\n            notification.textContent = message;\n            notification.style.position = 'fixed';\n            notification.style.top = '20px';\n            notification.style.right = '20px';\n            notification.style.padding = '1rem';\n            notification.style.borderRadius = '5px';\n            notification.style.backgroundColor = type === 'success' ? '#2ecc71' : '#e74c3c';\n            notification.style.color = 'white';\n            notification.style.zIndex = '1000';\n            document.body.appendChild(notification);\n            setTimeout(() => notification.remove(), 3000);\n        }\n\n        \u002F\u002F 用于防止 XSS 攻击的辅助函数\n        function escapeHtml(html) {\n            const div = document.createElement('div');\n            div.textContent = html;\n            return div.innerHTML;\n        }\n\n        \u002F\u002F 初始化 marked 配置\n        marked.setOptions({\n            breaks: true,\n            gfm: true,\n            headerIds: false\n        });\n    \u003C\u002Fscript>\n\u003C\u002Fbody>\n\n\u003C\u002Fhtml>\n","html",[68,152,149],{"__ignoreMap":83},[16,154,155],{},"The JavaScript shows the backend endpoints below:",[52,157,158,164,170,176,182],{},[55,159,160,163],{},[68,161,162],{"code":162},"POST \u002Fapi\u002Fauth",": authenticate the page.",[55,165,166,169],{},[68,167,168],{"code":168},"GET \u002Fapi\u002Fmemos",": retrieve posts, with pagination.",[55,171,172,175],{},[68,173,174],{"code":174},"POST \u002Fapi\u002Fmemos",": publish a new post.",[55,177,178,181],{},[68,179,180],{"code":180},"PUT \u002Fapi\u002Fmemos\u002F{uid}",": update a post.",[55,183,184,187],{},[68,185,186],{"code":186},"DELETE \u002Fapi\u002Fmemos\u002F{uid}",": delete a post.",[16,189,190,191,100],{},"Then implement these endpoints in ",[68,192,193],{"code":193},"worker.js",[77,195,198],{"className":196,"code":197,"language":82,"meta":83},[80],"import html from '.\u002Findex.html';\n\nconst CORRECT_PASSWORD = 'CORRECT_PASSWORD';        \u002F\u002F 设置你的密码    \u002F\u002F [!code highlight]\nconst CALLBACK_URL = 'https:\u002F\u002FCALLBACK_URL';        \u002F\u002F 设置回调 URL   \u002F\u002F [!code highlight]\nconst ALLOWED_ORIGINS = ['https:\u002F\u002Fexample.com'];    \u002F\u002F 允许请求的域名  \u002F\u002F [!code highlight]\n\n\u002F\u002F 生成随机 UID\nfunction generateUID() {\n  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';\n  let result = '';\n  for (let i = 0; i \u003C 22; i++) {\n    const randomIndex = Math.floor(Math.random() * chars.length);\n    result += chars[randomIndex];\n  }\n  return result;\n}\n\u002F\u002F CORS 处理\nfunction handleCORS(request) {\n  const origin = request.headers.get('Origin');\n  const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];\n\n  const corsHeaders = {\n    'Access-Control-Allow-Origin': allowedOrigin,\n    'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',\n    'Access-Control-Allow-Headers': 'Content-Type, Authorization',\n    'Access-Control-Max-Age': '86400',\n  };\n  return corsHeaders;\n}\nfunction getCurrentTimeInISOFormat() {\n  const now = new Date();\n  \u002F\u002F 获取各个部分\n  const year = now.getUTCFullYear();\n  const month = String(now.getUTCMonth() + 1).padStart(2, '0'); \u002F\u002F 月份从零开始\n  const day = String(now.getUTCDate()).padStart(2, '0');\n  const hours = String(now.getUTCHours()).padStart(2, '0');\n  const minutes = String(now.getUTCMinutes()).padStart(2, '0');\n  const seconds = String(now.getUTCSeconds()).padStart(2, '0');\n  \u002F\u002F 组装成 ISO 8601 格式字符串\n  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;\n}\nasync function handleRequest(request, env) {\n  const url = new URL(request.url);\n  function validateAuth(request) {\n    const auth = request.headers.get('Authorization');\n    return auth === CORRECT_PASSWORD;\n  }\n  async function shouldNotify(uid) {\n    const indexStr = await env.KV.get('index');\n    if (!indexStr) return false;\n    const index = JSON.parse(indexStr);\n    return index.indexOf(uid) \u003C 10;\n  }\n  async function executeCallback() {\n    try {\n      await fetch(CALLBACK_URL);\n    } catch (error) {\n      console.error('Callback failed:', error);\n    }\n  }\n  const corsHeaders = handleCORS(request);\n  \n  \u002F\u002F 处理 CORS 预检请求\n  if (request.method === 'OPTIONS') {\n    return new Response(null, {\n      headers: handleCORS(request),\n    });\n  }\n  \u002F\u002F 管理页面\n  if (url.pathname === '\u002Fmanage') {\n    return new Response(html, {\n      headers: { 'Content-Type': 'text\u002Fhtml' },\n    });\n  }\n  \u002F\u002F 验证密码\n  if (url.pathname === '\u002Fapi\u002Fauth' && request.method === 'POST') {\n    const { password } = await request.json();\n    return new Response(\n      JSON.stringify({ success: password === CORRECT_PASSWORD }),\n      {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      }\n    );\n  }\n  \u002F\u002F API 路由处理\n  if (url.pathname.startsWith('\u002Fapi\u002Fmemos')) {\n    \u002F\u002F 获取说说列表\n    if (request.method === 'GET') {\n      const offset = parseInt(url.searchParams.get('offset')) || 0;\n      const limit = parseInt(url.searchParams.get('limit')) || 10;\n      const indexStr = await env.KV.get('index');\n      const index = indexStr ? JSON.parse(indexStr) : [];\n      const pageUids = index.slice(offset, offset + limit);\n      const posts = await Promise.all(\n        pageUids.map(uid => env.KV.get(uid).then(JSON.parse))\n      );\n      return new Response(JSON.stringify({\n        offset,\n        limit,\n        data: posts,\n        total: index.length,\n        hasMore: (offset + limit) \u003C index.length,\n      }), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 需要验证的操作\n    if (!validateAuth(request)) {\n      return new Response('Unauthorized', {\n        status: 401,\n        headers: corsHeaders\n      });\n    }\n    \u002F\u002F 发布新说说\n    if (request.method === 'POST') {\n      const { content } = await request.json();\n      if (!content || !content.trim()) {\n        return new Response('Content cannot be empty', {\n          status: 400,\n          headers: corsHeaders\n        });\n      }\n      const indexStr = await env.KV.get('index');\n      const index = indexStr ? JSON.parse(indexStr) : [];\n      let uid = generateUID();\n      while (true) {\n        if (!index.includes(uid)) {\n          break;\n        }\n        uid = generateUID();\n      }\n      const post = {\n        uid,\n        createTime: getCurrentTimeInISOFormat(),\n        content: content.trim()\n      };\n      index.unshift(uid);\n      await Promise.all([\n        env.KV.put('index', JSON.stringify(index)),\n        env.KV.put(uid, JSON.stringify(post))\n      ]);\n      await executeCallback();\n      return new Response(JSON.stringify(post), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 编辑说说\n    if (request.method === 'PUT') {\n      const uid = url.pathname.split('\u002F').pop();\n      const { content } = await request.json();\n      if (!content || !content.trim()) {\n        return new Response('Content cannot be empty', {\n          status: 400,\n          headers: corsHeaders\n        });\n      }\n      const postStr = await env.KV.get(uid);\n      if (!postStr) {\n        return new Response('Post not found', {\n          status: 404,\n          headers: corsHeaders\n        });\n      }\n      const post = JSON.parse(postStr);\n      post.content = content.trim();\n      await env.KV.put(uid, JSON.stringify(post));\n      \u002F\u002F 检查是否需要回调\n      if (await shouldNotify(uid)) {\n        await executeCallback();\n      }\n      return new Response(JSON.stringify(post), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n    \u002F\u002F 删除说说\n    if (request.method === 'DELETE') {\n      const uid = url.pathname.split('\u002F').pop();\n      const indexStr = await env.KV.get('index');\n      if (!indexStr) {\n        return new Response('Post not found', {\n          status: 404,\n          headers: corsHeaders\n        });\n      }\n      const needCallback = await shouldNotify(uid);\n      const index = JSON.parse(indexStr);\n      const newIndex = index.filter(id => id !== uid);\n      await Promise.all([\n        env.KV.put('index', JSON.stringify(newIndex)),\n        env.KV.delete(uid)\n      ]);\n      if (needCallback) {\n        await executeCallback();\n      }\n      return new Response(JSON.stringify({ success: true }), {\n        headers: {\n          'Content-Type': 'application\u002Fjson',\n          ...corsHeaders\n        },\n      });\n    }\n  }\n  return new Response('Not Found', {\n    status: 404,\n    headers: corsHeaders\n  });\n}\nexport default {\n  async fetch(request, env) {\n    try {\n      return handleRequest(request, env);\n    } catch (error) {\n      return new Response(`Internal Server Error: ${error.message}`, {\n        status: 500,\n        headers: handleCORS(request)\n      });\n    }\n  },\n};\n",[68,199,197],{"__ignoreMap":83},[16,201,202],{},"Configure the three constants at the top:",[52,204,205,211,217],{},[55,206,207,210],{},[68,208,209],{"code":209},"CORRECT_PASSWORD",": the admin page password.",[55,212,213,216],{},[68,214,215],{"code":215},"CALLBACK_URL",": the callback URL triggered after publishing, editing, or deleting a post.",[55,218,219,222],{},[68,220,221],{"code":221},"ALLOWED_ORIGINS",": the allowed origins for CORS. Include at least your blog domain and admin page domain.",[16,224,225],{},"Once configured, click Deploy.",[16,227,228,229,232,233,236,237,239,240,242],{},"Because of the Great Firewall, the default ",[68,230,231],{"code":231},"workers.dev"," domain is difficult to access from China. Give the Worker a custom domain instead. Under ",[68,234,235],{"code":235},"memos details → Settings → Domains & Routes",", add a custom domain hosted on Cloudflare. Remember to add it to ",[68,238,221],{"code":221}," in ",[68,241,193],{"code":193}," too.",[16,244,245,246,249],{},"The admin page is now ready at ",[68,247,248],{"code":248},"https:\u002F\u002F{your-domain}\u002Fmanage",". Enter the password, then enjoy!",[11,251,253],{"id":252},"frontend","Frontend",[16,255,256],{},"Thanks to VitePress, writing the microblog frontend as a Vue component and embedding it in the blog is straightforward.",[16,258,259],{},"First, install the markedjs dependency. With pnpm:",[77,261,266],{"className":262,"code":264,"language":265,"meta":83},[263],"language-shell","pnpm add marked\n","shell",[68,267,264],{"__ignoreMap":83},[16,269,270,271,274,275,278,279,282],{},"Next to your blog’s theme configuration file, usually ",[68,272,273],{"code":273},"docs\u002F.vitepress\u002Ftheme\u002Findex.ts"," (the path and extension may differ), create a ",[68,276,277],{"code":277},"components"," directory if one does not already exist, and add ",[68,280,281],{"code":281},"memos.vue"," inside it.",[77,284,287],{"className":285,"code":286,"language":82,"meta":83},[80],"\u003Ctemplate>\n    \u003Cdiv class=\"memos-container\">\n        \u003Cdiv v-for=\"memo of memoList\" :key=\"memo.uid\">\n            \u003Cdiv class=\"card\">\n                \u003Cdiv class=\"header\">\n                    \u003Cspan class=\"time-text\">{{ memo.createTime }}\u003C\u002Fspan>\n                \u003C\u002Fdiv>\n\n                \u003Cdiv class=\"memo-content\" v-html=\"memo.content\" \u002F>\n            \u003C\u002Fdiv>\n        \u003C\u002Fdiv>\n        \u003Cdiv v-if=\"hasMore\" class=\"load-more\">\n            \u003Cbutton @click=\"loadMoreMemos\" :disabled=\"isLoading\" class=\"load-more-button\">\n                \u003Cspan v-if=\"!isLoading\">加载更多\u003C\u002Fspan>\n                \u003Cspan v-else class=\"loading-spinner\">\u003C\u002Fspan>\n            \u003C\u002Fbutton>\n        \u003C\u002Fdiv>\n    \u003C\u002Fdiv>\n\u003C\u002Ftemplate>\n\n\u003Cscript setup lang=\"ts\">\nimport { marked, Tokens } from \"marked\"\nimport { reactive, toRefs, onMounted } from \"vue\"\nimport memosRaw from '..\u002F..\u002F..\u002F..\u002Fmemos.json'       \u002F\u002F [!code highlight]\n\ninterface memosRes {\n    data: memo[]\n    hasMore: boolean\n}\n\ninterface image {\n    name: string\n    filename: string\n    url: string\n}\n\ninterface memo {\n    uid: string\n    createTime: string\n    content: string\n}\n\nfunction convertToLocalTime(dateString: string, timeZone: string = 'Asia\u002FShanghai'): string {\n    \u002F\u002F 创建 Date 对象\n    const date = new Date(dateString);\n\n    \u002F\u002F 提取所需的时间组件\n    const options: Intl.DateTimeFormatOptions = {\n        timeZone: timeZone,\n        year: 'numeric',\n        month: '2-digit',\n        day: '2-digit',\n        hour: '2-digit',\n        minute: '2-digit',\n        second: '2-digit',\n        hour12: false \u002F\u002F 使用 24 小时制\n    };\n\n    const formatter = new Intl.DateTimeFormat('zh-CN', options);\n    const parts = formatter.formatToParts(date);\n\n    \u002F\u002F 构建最终输出格式\n    const year = parts.find(part => part.type === 'year')?.value;\n    const month = parts.find(part => part.type === 'month')?.value;\n    const day = parts.find(part => part.type === 'day')?.value;\n    const hour = parts.find(part => part.type === 'hour')?.value;\n    const minute = parts.find(part => part.type === 'minute')?.value;\n    const second = parts.find(part => part.type === 'second')?.value;\n\n    \u002F\u002F 拼接成目标格式\n    return `${year}-${month}-${day} ${hour}:${minute}:${second}`;\n}\n\nconst PAGE_SIZE = 10;\nconst data = reactive({\n    memoList: [] as memo[],\n    offset: 10, \u002F\u002F 从文件加载了 10 条，所以初始 offset 为 10\n    hasMore: true,\n    isLoading: false\n})\nconst { memoList, hasMore, isLoading } = toRefs(data);\n\nconst renderer = new marked.Renderer();\nrenderer.image = function({href, title, text}: Tokens.Image):string {\n  return `\n    \u003Cdiv class=\"img-container\">\n        \u003Cimg class=\"imgwrp\" loading=\"lazy\" src=\"${href}\" \u002F>\n    \u003C\u002Fdiv>\n  `\n};\nmarked.use({\n    renderer: renderer,\n    breaks: true,\n    gfm: true,\n})\n\nfunction processMemos(memos: memo[]) {\n  return memos.map(memo => ({\n    ...memo,\n    content: marked.parse(memo.content) as string,\n    createTime: convertToLocalTime(memo.createTime)\n  }));\n}\n\n\u002F\u002F 初始化数据\nonMounted(() => {\n  const initialMemos = memosRaw.data as memo[];\n  data.memoList = processMemos(initialMemos);\n});\n\nasync function loadMoreMemos() {\n  if (!data.hasMore || data.isLoading) return;\n  \n  data.isLoading = true;\n  try {\n    const url = `https:\u002F\u002F{你的域名}\u002Fapi\u002Fmemos?limit=${PAGE_SIZE}&offset=${data.offset}`;   \u002F\u002F [!code highlight]\n    const response = await fetch(url);\n    const result: memosRes = await response.json();\n    \n    const processedMemos = processMemos(result.data);\n    data.memoList.push(...processedMemos);\n    data.offset += result.data.length;\n    data.hasMore = result.hasMore;\n  } catch (error) {\n    console.error('Failed to load memos:', error);\n  } finally {\n    data.isLoading = false;\n  }\n}\n\u003C\u002Fscript>\n\n\u003Cstyle lang=\"scss\">\n.card {\n    border-style: solid;\n    margin-bottom: .5rem;\n    border-width: 1px; \n    position: relative;\n    border-radius: .5rem;\n    border-color: var(--vp-c-bg);\n    padding-top: .75rem;\n    padding-bottom: .75rem;\n    padding-left: 1rem;\n    padding-right: 1rem;\n    background-color: var(--memo-bg);\n    font-family: ui-sans-serif, system-ui, sans-serif, \"Apple Color Emoji\", \"Segoe UI Emoji\", Segoe UI Symbol, \"Noto Color Emoji\";\n\n    .header {\n        display: flex;\n        justify-content: space-between;\n        align-items: center;\n\n        .time-text {\n            display: inline-block;\n            font-size: .875rem;\n            text-decoration: none;\n            color: var(--memo-time)\n        }\n    }\n\n    .memo-content {\n        margin-top: 5px;\n        font-size: 1rem;\n        word-break: break-word;\n        color: var(--memo-content);\n\n        * {\n            margin: 0;\n        }\n\n        *:not(:first-child):not([hidden]) {\n            margin-top: .5rem;\n        }\n\n        .img-container {\n            width: 40%;\n\n            .imgwrp {\n                width:100%;\n                height: 100%;\n            }\n        }\n    \n    }\n    \n}\n\n.card:hover {\n    border-color: var(--memo-card-border);\n}\n\n.load-more {\n  text-align: center;\n  margin-top: 40px;\n  margin-bottom: 40px;\n\n  .load-more-button {\n    display: inline-flex;\n    align-items: center;\n    justify-content: center;\n    width: 120px; \u002F\u002F 固定宽度\n    height: 40px; \u002F\u002F 固定高度\n    background-color: transparent;\n    color: var(--vp-c-text-2);\n    border: 1px solid var(--vp-c-divider);\n    border-radius: 4px;\n    font-size: 14px;\n    font-weight: 500;\n    cursor: pointer;\n    transition: all 0.2s ease;\n    outline: none;\n\n    &:hover:not(:disabled) {\n      background-color: var(--vp-c-bg-soft);\n      color: var(--vp-c-text-1);\n      border-color: var(--vp-c-text-2);\n    }\n\n    &:active:not(:disabled) {\n      transform: translateY(1px);\n    }\n\n    &:disabled {\n      opacity: 0.5;\n      cursor: not-allowed;\n    }\n\n    .loading-spinner {\n      width: 14px;\n      height: 14px;\n      border: 2px solid var(--vp-c-text-3);\n      border-radius: 50%;\n      border-top-color: var(--vp-c-text-1);\n      animation: spin 0.8s linear infinite;\n    }\n  }\n}\n\n@keyframes spin {\n  to { transform: rotate(360deg); }\n}\n\u003C\u002Fstyle>\n",[68,288,286],{"__ignoreMap":83},[16,290,291,292,295],{},"Replace ",[68,293,294],{"code":294},"{你的域名}"," in the code with your Cloudflare Worker’s domain.",[16,297,298,299,302],{},"You may have noticed that the component’s initial content does not come from a Worker API request. It comes from a JSON file: ",[68,300,301],{"code":301},"import memosRaw from '..\u002F..\u002F..\u002F..\u002Fmemos.json'",". The Worker is queried only when you click Load More. Why?",[52,304,305,308],{},[55,306,307],{},"For the user experience: fetching initial data from the API leaves the page blank for a while on arrival, which feels unpleasant.",[55,309,310,311,315],{},"To ",[312,313,314],"mark",{},"save money",": the free Cloudflare Workers plan limits request counts. Loading the initial data statically cuts requests dramatically.",[16,317,318,321,322,324],{},[68,319,320],{"code":320},"memos.json"," contains the first ten posts, fetched from the API at build time. That is why the Worker has a ",[68,323,215],{"code":215},": it triggers a rebuild when you publish a new post or edit or delete one of the first ten. Look up the appropriate URL for your deployment platform. If you fetch everything dynamically, you do not need this callback.",[16,326,327,328,330,331,333,334,337,338,282],{},"The following code generates ",[68,329,320],{"code":320}," at build time. Next to the theme configuration file, usually ",[68,332,273],{"code":273}," (path and extension may vary), create a ",[68,335,336],{"code":336},"utils"," directory if needed, then add ",[68,339,340],{"code":340},"memos.js",[77,342,345],{"className":343,"code":344,"language":82,"meta":83},[80],"import https from 'https';\nimport { promises as fs } from 'fs';\n\nconst url = 'https:\u002F\u002F{你的域名}\u002Fapi\u002Fmemos?limit=10';\u002F\u002F [!code highlight]\n\nconst requestOptions = {\n    headers: {\n      'Accept-Encoding': '',\n    }\n};\n\n\u002F\u002F 发出 GET 请求\nhttps.get(url, requestOptions, (resp) => {\n  let data = [];\n\n  \u002F\u002F 逐步接收数据\n  resp.on('data', (chunk) => {\n    data.push(chunk);\n  });\n\n  \u002F\u002F 完成接收数据\n  resp.on('end', async () => {\n    try {\n      \u002F\u002F 将 Buffer 数组合并为一个 Buffer\n      const buffer = Buffer.concat(data);\n      const decodedData = buffer.toString('utf-8'); \u002F\u002F 假设返回的数据是 UTF-8 编码\n\n      \u002F\u002F 保存 JSON 数据到文件\n      await fs.writeFile('memos.json', decodedData);\n      console.log('JSON 数据已保存到 data.json');\n    } catch (e) {\n      console.error('解析 JSON 时出错:', e);\n    }\n  });\n\n}).on('error', (err) => {\n  console.error('获取数据时出错:', err);\n});\n",[68,346,344],{"__ignoreMap":83},[16,348,349,350,353,354,357],{},"Edit ",[68,351,352],{"code":352},"package.json"," in the blog root and prepend ",[68,355,356],{"code":356},"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js"," to both the dev and build commands. The exact place may vary; here is mine:",[77,359,364],{"className":360,"code":362,"language":363,"meta":83},[361],"language-json","{\n  ...\n  \"scripts\": {\n    \"dev\": \"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js && vitepress dev docs\",\n    \"build\": \"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js && vitepress build docs\",\n    \"serve\": \"vitepress serve docs\"\n  },\n  ...\n}\n","json",[68,365,362],{"__ignoreMap":83},[16,367,368,369,371,372,374,375,377],{},"Both dev and build now run ",[68,370,340],{"code":340}," first, generating ",[68,373,320],{"code":320}," in the blog root. Adjust the import path in ",[68,376,281],{"code":281}," to match your directory layout.",[16,379,380],{},"The component and data are ready. Next, register the component globally.",[16,382,383,384,386],{},"Import and register it in the theme configuration file, usually ",[68,385,273],{"code":273},", though the path and extension may differ.",[77,388,391],{"className":389,"code":390,"language":82,"meta":83},[80],"...\nimport Memos from '.\u002Fcomponents\u002Fmemos.vue'\n...\nexport default {\n    ...\n    enhanceApp({ app }) {\n        ...\n        app.component('Memos', Memos);\u002F\u002F [!code highlight]\n    }\n} satisfies Theme\n",[68,392,390],{"__ignoreMap":83},[16,394,395,396,100],{},"You can now insert the component anywhere in the blog with ",[68,397,398],{"code":398},"\u003CMemos \u002F>",[16,400,401],{},"Finally, create a standalone page just for this component.",[403,404,405,408],"blockquote",{},[16,406,407],{},"What? You have never used a standalone page in VitePress?",[16,409,410,411,414,415,418,419,425],{},"Create a pages directory at the root. Then, in VitePress’s main configuration—not the theme configuration; usually docs\u002F.vitepress\u002Fconfig.ts, though your path and extension may differ—add the rewrite rule ",[68,412,413],{"code":413},"'pages\u002F:file.md': ':file.md'",". Files under pages will then be accessible directly at ",[68,416,417],{"code":417},"\u002Ffilename",". See the ",[26,420,424],{"href":421,"rel":422},"https:\u002F\u002Fvitepress.dev\u002Fguide\u002Frouting#route-rewrites",[423],"nofollow","official documentation"," for rewrites.",[16,427,428],{},"Create balabala.md under pages with the following contents:",[77,430,435],{"className":431,"code":433,"language":434,"meta":83},[432],"language-markdown","---\ntitle: 碎碎念\nhidden: true\ncomment: false\nsidebar: false\naside: false\nreadingTime: false\nshowMeta: false\n---\n\n\u003CMemos \u002F>\n","markdown",[68,436,433],{"__ignoreMap":83},[16,438,439],{},"All done.",{"title":83,"searchDepth":441,"depth":441,"links":442},4,[443,445,449],{"id":13,"depth":444,"text":14},3,{"id":41,"depth":444,"text":42,"children":446},[447,448],{"id":46,"depth":441,"text":47},{"id":88,"depth":441,"text":89},{"id":252,"depth":444,"text":253},[451],"fiddling","2025-01-29 21:58:00","A microblog lets you share short thoughts whenever they occur, without the overhead of publishing a full static-blog post. This implementation uses Cloudflare Workers for the backend and KV for storage and management. A Vue component embedded in VitePress displays the updates, adding a lively, interactive element to the blog.",false,"md","https:\u002F\u002Fblog-img.774352199.xyz\u002FhZX6hr.webp",{"slots":458},{},true,"\u002Ffiddling\u002Fvitepress-memos-component",null,{"text":463,"minutes":464,"time":465,"words":466},"19 min read",18.96,1137600,3792,{"title":5,"description":453},"Add a VitePress microblog with a Vue component, Cloudflare Workers and KV, an editing interface, build-time prefetching, and paginated content loading.",{"loc":460,"images":470},[471],{"loc":38},"posts\u002Ffiddling\u002Fvitepress-memos-component",[474,475,476,477,478],"VitePress","Vue","Cloudflare Workers","Cloudflare KV","Microblogging","tech","mEBb53huhphjsy5NCVWk7TgUuSMMuZ6IH5ALiiiyCR4",[482,487],{"title":483,"path":484,"stem":485,"date":486,"type":479,"children":-1},"Transparent Proxying and Traffic Routing with OPNsense","\u002Ffiddling\u002Fopnsense-transparent-proxy","posts\u002Ffiddling\u002Fopnsense-transparent-proxy","2025-01-16 23:09:00",{"title":488,"path":489,"stem":490,"date":491,"type":479,"children":-1},"Routing Selected VPS Traffic Through WARP over IPv6","\u002Ffiddling\u002Fvps-warp-ipv6","posts\u002Ffiddling\u002Fvps-warp-ipv6","2025-03-15 16:24:00",1789914052378]