[{"data":1,"prerenderedAt":491},["ShallowReactive",2],{"content:\u002Fja\u002Ffiddling\u002Fvitepress-memos-component":3,"surround:\u002Fja\u002Ffiddling\u002Fvitepress-memos-component":480},{"id":4,"title":5,"authorship":6,"body":7,"categories":449,"date":451,"description":452,"draft":453,"extension":454,"image":455,"meta":456,"navigation":458,"path":459,"permalink":460,"published":460,"readingTime":461,"recommend":460,"references":460,"seo":466,"seoDescription":467,"seoTitle":460,"sitemap":468,"stem":471,"tags":472,"type":478,"__hash__":479},"content_ja\u002Fposts\u002Ffiddling\u002Fvitepress-memos-component.md","VitePressにひとこと投稿機能を実装する","human-only",{"type":8,"value":9,"toc":439},"minimark",[10,14,18,21,30,38,42,46,49,62,74,84,87,98,135,138,145,153,156,188,195,201,204,224,227,244,251,254,257,260,268,283,289,296,303,315,324,340,346,357,365,377,380,385,391,398,401,425,428,436],[11,12,13],"h3",{"id":13},"はじめに",[15,16,17],"p",{},"動的ブログには、ひとこと投稿の機能がよくあります。本質的には特殊な記事ですが、動的ブログの即時性を生かして、書いたらすぐ公開できます。",[15,19,20],{},"静的ブログはローカルやサーバーでHTMLへコンパイルしてからデプロイするため、即時性には欠けます。長い記事ならパソコンの前で書いてGitで公開しても、それほど面倒ではありません。ただ、ひとこと投稿のためにパソコンを開くのは心理的な負担が大きいです。スマートフォンでGitを操作するのも面倒で、あまりスマートではありません。考えるうちに、もう投稿しなくていいかとなってしまいます。",[15,22,23,24,29],{},"そこで、ひとこと投稿システムのフロントエンドとバックエンドを作りました。このブログの",[25,26,28],"a",{"href":27},"\u002Fja\u002Fmemos","ひとこと","がその完成形です。バックエンドはCloudflare Workersで、保存先も近場にある太っ腹なCloudflareのKVにしました。簡単な管理画面も用意しています。ブログはVitePressなので、フロントエンドはVueコンポーネントとして作り、専用ページに埋め込みました。",[15,31,32,33],{},"フロント側は実際のページを見てもらうとして、管理画面はこんな感じです。\n",[34,35],"img",{"alt":36,"src":37},"ひとこと投稿の管理画面","https:\u002F\u002Fblog-img.774352199.xyz\u002F2025\u002F8551751fe98e55c4159d28b9ff5b9473.png",[11,39,41],{"id":40},"バックエンドcloudflare-workers-kv","バックエンド：Cloudflare Workers + KV",[43,44,45],"h4",{"id":45},"概要",[15,47,48],{},"バックエンドには次の機能があります。",[50,51,52,56,59],"ul",{},[53,54,55],"li",{},"投稿の追加・編集・削除（基本機能）。",[53,57,58],{},"管理画面とすべての書き込みAPIに認証を設け、十分な安全性を確保。",[53,60,61],{},"markedによるMarkdownのリアルタイムプレビュー。",[15,63,64,65,69,70,73],{},"KVには",[66,67,68],"code",{"code":68},"index","というキーを保存し、その値を全投稿のインデックスとなるUID配列にします。各投稿は",[66,71,72],{"code":72},"uid","をキーとして個別に保存し、値は次の形式です。",[75,76,82],"pre",{"className":77,"code":79,"language":80,"meta":81},[78],"language-js","{\n    \"uid\":\"唯一 id\",\n    \"createTime\":\"发布时间\",\n    \"content\":\"说说内容\",\n}\n","js","",[66,83,79],{"__ignoreMap":81},[43,85,86],{"id":86},"実装",[15,88,89,90,93,94,97],{},"まず、投稿用のキーと値を保存するCloudflare KV名前空間を作ります。",[66,91,92],{"code":92},"アカウントホーム → ストレージとデータベース → KV","で作成をクリックします。名前は覚えておけば何でもよく、私は単純に",[66,95,96],{"code":96},"memos","にしました。",[15,99,100,101,104,105,108,109,112,113,116,117,120,121,123,124,127,128,130,131,134],{},"次に、処理を担当するCloudflare Workerを作成します。",[66,102,103],{"code":103},"アカウントホーム → コンピューティング（Workers）→ WorkersとPages","から作成します。これも名前は自由で、私は",[66,106,107],{"code":107},"memos-api","にしました。作成したWorkerの名前をクリックして詳細を開き、",[66,110,111],{"code":111},"設定 → バインディング","で追加します。",[66,114,115],{"code":115},"KV名前空間","を選び、変数名を",[66,118,119],{"code":119},"KV","、名前空間を先ほどのもの（私の場合は",[66,122,96],{"code":96},"）にします。これでコードから",[66,125,126],{"code":126},"env.KV","で直接",[66,129,96],{"code":96},"を操作できます。最後に上部右側の",[66,132,133],{"code":133},"コードを編集","をクリックします。",[15,136,137],{},"ここからはコードの時間です！",[15,139,140,141,144],{},"まず管理画面のHTML、CSS、JavaScriptを置く",[66,142,143],{"code":143},"index.html","を作成します。",[75,146,151],{"className":147,"code":149,"language":150,"meta":81},[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",[66,152,149],{"__ignoreMap":81},[15,154,155],{},"JavaScriptを見ると、バックエンドのエンドポイントは次のとおりです。",[50,157,158,164,170,176,182],{},[53,159,160,163],{},[66,161,162],{"code":162},"POST \u002Fapi\u002Fauth","：ページの認証。",[53,165,166,169],{},[66,167,168],{"code":168},"GET \u002Fapi\u002Fmemos","：投稿を取得。ページネーション対応。",[53,171,172,175],{},[66,173,174],{"code":174},"POST \u002Fapi\u002Fmemos","：新規投稿。",[53,177,178,181],{},[66,179,180],{"code":180},"PUT \u002Fapi\u002Fmemos\u002F{uid}","：投稿を更新。",[53,183,184,187],{},[66,185,186],{"code":186},"DELETE \u002Fapi\u002Fmemos\u002F{uid}","：投稿を削除。",[15,189,190,191,194],{},"続いて",[66,192,193],{"code":193},"worker.js","でこれらを実装します。",[75,196,199],{"className":197,"code":198,"language":80,"meta":81},[78],"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",[66,200,198],{"__ignoreMap":81},[15,202,203],{},"先頭の定数3つを設定してください。",[50,205,206,212,218],{},[53,207,208,211],{},[66,209,210],{"code":210},"CORRECT_PASSWORD","：管理画面のパスワード。",[53,213,214,217],{},[66,215,216],{"code":216},"CALLBACK_URL","：投稿の公開・更新・削除後に呼び出すコールバックURL。",[53,219,220,223],{},[66,221,222],{"code":222},"ALLOWED_ORIGINS","：CORSでアクセスを許可するドメインのリスト。少なくともブログと管理画面の2つのドメインを入れます。",[15,225,226],{},"設定したらデプロイをクリックします。",[15,228,229,230,233,234,237,238,240,241,243],{},"GFWの影響で標準の",[66,231,232],{"code":232},"workers.dev","ドメインはアクセスしにくいため、Workerには独自ドメインを設定したほうがよいです。",[66,235,236],{"code":236},"memosの詳細 → 設定 → ドメインとルート","でカスタムドメインを追加し、Cloudflareで管理しているドメインを入力します。",[66,239,193],{"code":193},"の",[66,242,222],{"code":222},"にも追加してください。",[15,245,246,247,250],{},"これで管理画面が使えます。URLは",[66,248,249],{"code":249},"https:\u002F\u002F{自分のドメイン}\u002Fmanage","です。パスワードを入力したら、あとは楽しんでください！",[11,252,253],{"id":253},"フロントエンド",[15,255,256],{},"VitePressのおかげで、Vueコンポーネントとしてひとこと投稿の画面を書き、ブログへ簡単に埋め込めます。",[15,258,259],{},"まずmarkedjsをインストールします。pnpmなら次のコマンドです。",[75,261,266],{"className":262,"code":264,"language":265,"meta":81},[263],"language-shell","pnpm add marked\n","shell",[66,267,264],{"__ignoreMap":81},[15,269,270,271,274,275,278,279,282],{},"テーマ設定ファイル（通常は",[66,272,273],{"code":273},"docs\u002F.vitepress\u002Ftheme\u002Findex.ts","ですが、パスや拡張子は異なる場合があります）と同じ階層に",[66,276,277],{"code":277},"components","フォルダーを作り、その中に",[66,280,281],{"code":281},"memos.vue","を作成します。フォルダーがすでにあればそのまま使います。",[75,284,287],{"className":285,"code":286,"language":80,"meta":81},[78],"\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",[66,288,286],{"__ignoreMap":81},[15,290,291,292,295],{},"コード内の",[66,293,294],{"code":294},"{你的域名}","をCloudflare Workerのドメインに置き換えてください。",[15,297,298,299,302],{},"気づいた方もいると思いますが、このコンポーネントの初期データはWorkerのAPIからではなく、JSONファイル（",[66,300,301],{"code":301},"import memosRaw from '..\u002F..\u002F..\u002F..\u002Fmemos.json'","）から読み込みます。「もっと読む」を押したときだけWorkerへ取りに行きます。理由は次のとおりです。",[50,304,305,308],{},[53,306,307],{},"使い勝手のためです。初期データをAPIから取得すると、ページを開いてから取得完了まで空白になり、体験がよくありません。",[53,309,310,314],{},[311,312,313],"mark",{},"節約","のためです。Cloudflare Workersの無料枠にはリクエスト数の制限があり、初期データを静的に読むと大きく削減できます。",[15,316,317,320,321,323],{},[66,318,319],{"code":319},"memos.json","には、ビルド時にAPIから取得した先頭10件の投稿を保存します。Workerに",[66,322,216],{"code":216},"があるのは、新規投稿や先頭10件の編集・削除時に再ビルドするためです。具体的なURLはデプロイ先のプラットフォームに合わせて調べてください。全件を動的に取得するなら、このコールバックは不要です。",[15,325,326,327,329,330,332,333,278,336,339],{},"次のコードでビルド時に",[66,328,319],{"code":319},"を生成します。テーマ設定ファイル（通常は",[66,331,273],{"code":273},"。パスや拡張子は異なる場合があります）と同じ階層に",[66,334,335],{"code":335},"utils",[66,337,338],{"code":338},"memos.js","を作成します。既存のフォルダーがあればそれを使います。",[75,341,344],{"className":342,"code":343,"language":80,"meta":81},[78],"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",[66,345,343],{"__ignoreMap":81},[15,347,348,349,352,353,356],{},"ブログルートの",[66,350,351],{"code":351},"package.json","を編集し、devとbuildの両方のコマンドの前に",[66,354,355],{"code":355},"node docs\u002F.vitepress\u002Ftheme\u002Futils\u002Fmemos.js","を追加します。追加箇所は構成によって異なるので、私の例を載せます。",[75,358,363],{"className":359,"code":361,"language":362,"meta":81},[360],"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",[66,364,361],{"__ignoreMap":81},[15,366,367,368,370,371,373,374,376],{},"これでdevでもbuildでも最初に",[66,369,338],{"code":338},"が実行され、ブログルートに",[66,372,319],{"code":319},"が生成されます。ディレクトリ構成に合わせて",[66,375,281],{"code":281},"のimportパスを調整してください。",[15,378,379],{},"コンポーネントとデータがそろったので、次はグローバルコンポーネントとして登録します。",[15,381,270,382,384],{},[66,383,273],{"code":273},"。パスや拡張子が異なる場合があります）で読み込み、登録します。",[75,386,389],{"className":387,"code":388,"language":80,"meta":81},[78],"...\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",[66,390,388],{"__ignoreMap":81},[15,392,393,394,397],{},"これでブログのどこでも",[66,395,396],{"code":396},"\u003CMemos \u002F>","で直接コンポーネントを埋め込めます。",[15,399,400],{},"最後に、このコンポーネント専用の独立ページを作ります。",[402,403,404,407],"blockquote",{},[15,405,406],{},"えっ、VitePressで独立ページを使ったことがないですって？",[15,408,409,410,413,414,417,418,424],{},"まずルートにpagesフォルダーを作ります。次にVitePress本体の設定ファイル（テーマ設定ではありません。通常はdocs\u002F.vitepress\u002Fconfig.tsですが、パスや拡張子が違うこともあります）にrewritesルール",[66,411,412],{"code":412},"'pages\u002F:file.md': ':file.md'","を追加します。これでpages配下のファイルに",[66,415,416],{"code":416},"\u002Fファイル名","で直接アクセスできます。rewritesについては",[25,419,423],{"href":420,"rel":421},"https:\u002F\u002Fvitepress.dev\u002Fguide\u002Frouting#route-rewrites",[422],"nofollow","公式ドキュメント","を参照してください。",[15,426,427],{},"pagesフォルダーにbalabala.mdを作り、次の内容を書きます。",[75,429,434],{"className":430,"code":432,"language":433,"meta":81},[431],"language-markdown","---\ntitle: 碎碎念\nhidden: true\ncomment: false\nsidebar: false\naside: false\nreadingTime: false\nshowMeta: false\n---\n\n\u003CMemos \u002F>\n","markdown",[66,435,432],{"__ignoreMap":81},[15,437,438],{},"これで完成です。",{"title":81,"searchDepth":440,"depth":440,"links":441},4,[442,444,448],{"id":13,"depth":443,"text":13},3,{"id":40,"depth":443,"text":41,"children":445},[446,447],{"id":45,"depth":440,"text":45},{"id":86,"depth":440,"text":86},{"id":253,"depth":443,"text":253},[450],"fiddling","2025-01-29 21:58:00","ひとこと投稿機能があれば、静的ブログの記事公開に伴う手間や心理的な負担を減らし、短い思いつきをいつでも共有できます。Cloudflare Workersでバックエンドを実装し、KVで投稿を保存・管理します。フロントエンドはVitePressにVueコンポーネントを埋め込み、投稿を手軽に表示してブログに動きと交流の場を加えます。",false,"md","https:\u002F\u002Fblog-img.774352199.xyz\u002FhZX6hr.webp",{"slots":457},{},true,"\u002Ffiddling\u002Fvitepress-memos-component",null,{"text":462,"minutes":463,"time":464,"words":465},"24 min read",23.345,1400700,4669,{"title":5,"description":452},"VitePressにひとこと投稿機能を追加。WorkersとKVで管理APIを作り、Vueで表示します。ビルド時の先読み、ページ分割、更新時のコールバックも組み込みます。",{"loc":459,"images":469},[470],{"loc":37},"posts\u002Ffiddling\u002Fvitepress-memos-component",[473,474,475,476,477],"VitePress","Vue","Cloudflare Workers","Cloudflare KV","マイクロブログ","tech","4WtEd59KJSXzXaN9m9nl9GEBtFXq25Hr7LNeivJNi_o",[481,486],{"title":482,"path":483,"stem":484,"date":485,"type":478,"children":-1},"OPNsenseで透過プロキシとトラフィック振り分けを実現する","\u002Ffiddling\u002Fopnsense-transparent-proxy","posts\u002Ffiddling\u002Fopnsense-transparent-proxy","2025-01-16 23:09:00",{"title":487,"path":488,"stem":489,"date":490,"type":478,"children":-1},"VPSの特定の通信をWARP経由のIPv6で外に出す","\u002Ffiddling\u002Fvps-warp-ipv6","posts\u002Ffiddling\u002Fvps-warp-ipv6","2025-03-15 16:24:00",1789914058902]