diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..42c5b46 Binary files /dev/null and b/favicon.ico differ diff --git a/logo.png b/logo.png new file mode 100644 index 0000000..963e394 Binary files /dev/null and b/logo.png differ diff --git a/migrations/0003_add_view_count.sql b/migrations/0003_add_view_count.sql new file mode 100644 index 0000000..da0c168 --- /dev/null +++ b/migrations/0003_add_view_count.sql @@ -0,0 +1 @@ +ALTER TABLE posts ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0; diff --git a/public/css/markdown.css b/public/css/markdown.css new file mode 100644 index 0000000..268255e --- /dev/null +++ b/public/css/markdown.css @@ -0,0 +1,127 @@ +.markdown-body { + overflow-wrap: break-word; +} +.markdown-body > *:first-child { + margin-top: 0; +} +.markdown-body > *:last-child { + margin-bottom: 0; +} +.markdown-body h1 { + font-size: 1.75rem; + margin: 24px 0 12px; +} +.markdown-body h2 { + font-size: 1.45rem; + margin: 22px 0 10px; +} +.markdown-body h3 { + font-size: 1.2rem; + margin: 18px 0 8px; +} +.markdown-body h4, +.markdown-body h5, +.markdown-body h6 { + font-size: 1.05rem; + margin: 16px 0 8px; +} +.markdown-body p { + margin: 0 0 12px; +} +.markdown-body ul, +.markdown-body ol { + margin: 0 0 12px; + padding-left: 1.6em; +} +.markdown-body li { + margin: 0 0 4px; +} +.markdown-body li > p { + margin: 0 0 6px; +} +.markdown-body blockquote { + border-left: 4px solid #ccc; + margin: 0 0 16px; + padding: 2px 0 2px 16px; + color: #444; +} +.markdown-body blockquote > :last-child { + margin-bottom: 0; +} +.markdown-body pre { + padding: 0; + margin: 0 0 16px; + border-radius: 6px; + overflow-x: auto; + font-size: 0.9em; + background: #f6f8fa; +} +.markdown-body pre code.hljs { + display: block; + padding: 12px 14px; + overflow-x: auto; + background: transparent; +} +.markdown-body code { + font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace; + font-size: 0.9em; +} +.markdown-body pre code { + background: none; + padding: 0; + font-size: inherit; +} +.markdown-body :not(pre) > code { + background: #f0f0f0; + padding: 2px 6px; + border-radius: 4px; +} +.markdown-body table { + border-collapse: collapse; + width: 100%; + margin: 0 0 16px; + font-size: 0.95em; +} +.markdown-body th, +.markdown-body td { + border: 1px solid #ccc; + padding: 8px 10px; + vertical-align: top; +} +.markdown-body th { + background: #f5f5f5; + font-weight: 600; +} +.markdown-body hr { + margin: 20px 0; +} +.markdown-body a { + text-decoration: underline; +} +.markdown-body li:has(> input[type="checkbox"]) { + list-style: none; + margin-left: -1.5em; + padding-left: 0; +} +.markdown-body input[type="checkbox"] { + margin-right: 6px; + vertical-align: middle; +} +.markdown-body .katex-display { + overflow-x: auto; + overflow-y: hidden; + max-width: 100%; + padding: 8px 0; +} +.markdown-body .katex { + max-width: 100%; +} +.markdown-body .mermaid { + margin: 16px 0; + overflow-x: auto; + text-align: center; +} +.markdown-pending .markdown-loading { + color: #666; + margin: 0; +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..42c5b46 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/js/markdown-client.js b/public/js/markdown-client.js new file mode 100644 index 0000000..838d1b8 --- /dev/null +++ b/public/js/markdown-client.js @@ -0,0 +1,96 @@ +import { marked } from "https://esm.sh/marked@15"; +import markedKatex from "https://esm.sh/marked-katex-extension@5?deps=marked@15,katex@0.16"; +import hljs from "https://esm.sh/highlight.js@11"; +import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs"; + +marked.use( + markedKatex({ + throwOnError: false, + nonStandard: true + }) +); +marked.setOptions({ gfm: true, breaks: false }); + +function decodeHtmlEntities(str) { + return String(str) + .replace(/ /g, "\u00a0") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => { + const code = Number(n); + return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : ""; + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => { + const code = parseInt(h, 16); + return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : ""; + }); +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function mermaidFencedToDiv(html) { + return html.replace( + /
([\s\S]*?)<\/code><\/pre>/gi,
+    (_, inner) => {
+      const raw = decodeHtmlEntities(inner);
+      return `
${escapeHtml(raw)}
`; + } + ); +} + +function decodeMdFromB64(b64) { + return decodeURIComponent(escape(atob(b64))); +} + +async function renderOne(el) { + const b64 = el.getAttribute("data-b64-md"); + if (!b64) return; + let md; + try { + md = decodeMdFromB64(b64); + } catch { + el.innerHTML = "

正文解码失败。

"; + el.classList.remove("markdown-pending"); + return; + } + el.removeAttribute("data-b64-md"); + let html = marked.parse(md, { async: false }); + if (typeof html !== "string") html = ""; + html = mermaidFencedToDiv(html); + el.innerHTML = html; + el.classList.remove("markdown-pending"); + el.querySelectorAll("pre code").forEach((block) => { + try { + hljs.highlightElement(block); + } catch { + /* ignore unknown languages */ + } + }); + const mNodes = el.querySelectorAll(".mermaid"); + if (mNodes.length) { + mermaid.initialize({ startOnLoad: false, theme: "neutral" }); + await mermaid.run({ nodes: mNodes }); + } +} + +function run() { + document.querySelectorAll(".markdown-body[data-b64-md]").forEach((el) => { + renderOne(el).catch(() => { + el.innerHTML = "

渲染失败。

"; + el.classList.remove("markdown-pending"); + }); + }); +} + +if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run); +else run(); diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..963e394 Binary files /dev/null and b/public/logo.png differ diff --git a/worker.js b/worker.js index 88cb05c..c738dde 100644 --- a/worker.js +++ b/worker.js @@ -9,7 +9,7 @@ export default { return htmlResponse(renderLayout("配置缺失", `

DB 绑定未配置

请先在 wrangler.toml 里配置 D1 数据库。

- `), 500); + `, env), 500); } if (request.method === "OPTIONS") { @@ -17,6 +17,10 @@ export default { } try { + if (path === "/admin/session" && request.method === "POST") { + return await handleAdminSession(request, env); + } + if (request.method === "GET") { if (path === "/") return await handleIndex(env, url); if (path.startsWith("/post/")) return await handlePost(env, path.slice(6)); @@ -45,12 +49,12 @@ export default { if (request.method === "DELETE") return await handleApiDelete(request, env, idOrSlug); } - return notFoundPage(); + return notFoundPage(env); } catch (error) { return htmlResponse(renderLayout("错误", `

发生错误

${escapeHtml(error?.stack || error?.message || String(error))}
- `), 500); + `, env), 500); } } }; @@ -59,13 +63,9 @@ async function handleIndex(env, url) { const q = (url.searchParams.get("q") || "").trim(); const posts = await listPosts(env.DB, { publishedOnly: true, q }); return htmlResponse(renderLayout(pageTitle(env, "首页"), ` -
-

${escapeHtml(pageTitle(env))}

-

后台

-
${q ? `

搜索:${escapeHtml(q)}

` : ""} ${posts.length ? posts.map(renderPostCard).join("") : "

暂无文章。

"} - `)); + `, env)); } async function handlePost(env, slug) { @@ -73,20 +73,25 @@ async function handlePost(env, slug) { "SELECT * FROM posts WHERE slug = ? AND published = 1 LIMIT 1" ).bind(decodeURIComponent(slug)).first(); - if (!post) return notFoundPage(); + if (!post) return notFoundPage(env); + + await env.DB.prepare( + "UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?" + ).bind(post.id).run(); + const views = (Number(post.view_count) || 0) + 1; return htmlResponse(renderLayout(`${post.title} - ${pageTitle(env)}`, `

返回首页

${escapeHtml(post.title)}

-

${formatDateTime(post.updated_at)}

-
${renderMarkdown(post.content)}
+

${formatDateTime(post.updated_at)}浏览 ${views}

+ ${markdownPlaceholder(post.content)}
- `)); + `, env, { markdownPage: true })); } async function handleAdminPage(request, env, url, path) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; let post = blankPost(); @@ -143,11 +148,11 @@ async function handleAdminPage(request, env, url, path) { ${posts.length ? posts.map(renderAdminCard).join("") : "

没有找到文章。

"} - `)); + `, env)); } async function handleAdminSave(request, env, url) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; const data = await readBody(request); @@ -163,7 +168,7 @@ async function handleAdminSave(request, env, url) { return htmlResponse(renderLayout("表单错误", `

标题和内容不能为空

返回后台

- `), 400); + `, env), 400); } const slug = await uniqueSlug(env.DB, slugify(slugBase), id); @@ -171,7 +176,7 @@ async function handleAdminSave(request, env, url) { if (id) { const exists = await getPostById(env.DB, id); - if (!exists) return notFoundPage(); + if (!exists) return notFoundPage(env); await env.DB.prepare(` UPDATE posts SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ? @@ -189,7 +194,7 @@ async function handleAdminSave(request, env, url) { } async function handleAdminDelete(request, env, url) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; const data = await readBody(request); @@ -198,13 +203,36 @@ async function handleAdminDelete(request, env, url) { return htmlResponse(renderLayout("表单错误", `

无效的文章 ID

返回后台

- `), 400); + `, env), 400); } await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(id).run(); return Response.redirect(new URL(`/admin?deleted=1`, url), 303); } +async function handleAdminSession(request, env) { + const password = cleanText(env.ADMIN_PASSWORD); + if (!password) { + return jsonResponse({ ok: false, error: "not_configured" }, 503); + } + + const data = await readBody(request); + const token = cleanText(data.token); + if (token !== password) { + return jsonResponse({ ok: false, error: "invalid" }, 401); + } + + const value = await adminSessionCookieValue(env); + const maxAge = 60 * 60 * 24 * 7; + const secure = new URL(request.url).protocol === "https:"; + const parts = [`sb_admin=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`]; + if (secure) parts.push("Secure"); + + return jsonResponse({ ok: true }, 200, { + "Set-Cookie": parts.join("; ") + }); +} + async function handleApiList(env, url) { const q = (url.searchParams.get("q") || "").trim(); const posts = await listPosts(env.DB, { publishedOnly: false, q }); @@ -218,7 +246,7 @@ async function handleApiRead(env, idOrSlug) { } async function handleApiCreate(request, env) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; const data = await readBody(request); @@ -227,7 +255,7 @@ async function handleApiCreate(request, env) { } async function handleApiUpdate(request, env, idOrSlug) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; const data = await readBody(request); @@ -238,7 +266,7 @@ async function handleApiUpdate(request, env, idOrSlug) { } async function handleApiDelete(request, env, idOrSlug) { - const auth = requireAdmin(request, env); + const auth = await requireAdmin(request, env); if (auth) return auth; const post = await readPost(env.DB, idOrSlug); @@ -336,11 +364,12 @@ function slugify(value) { function renderPostCard(post) { const excerpt = post.excerpt || stripMarkdown(post.content).slice(0, 180); + const views = Number(post.view_count) || 0; return ` -
+

${escapeHtml(post.title)}

-

${formatDateTime(post.updated_at)}

-

${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}

+

${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}

+
`; } @@ -361,18 +390,75 @@ function renderAdminCard(post) { `; } -function renderLayout(title, body) { +function renderLayout(title, body, env, options = {}) { + const site = cleanText(env?.SITE_TITLE) || DEFAULT_SITE_TITLE; + const mdHead = options.markdownPage + ? ` + + + +` + : ""; + const mdScript = options.markdownPage + ? `\n ` + : ""; return ` ${escapeHtml(title)} + + ${mdHead}