${escapeHtml(post.title)}
-${formatDateTime(post.updated_at)}
-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(/([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)}
+
+ ${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}