chore: sync local changes to Gitea

This commit is contained in:
shumengya
2026-06-24 22:10:27 +08:00
parent 10ff96f32b
commit ad8997a5ec
105 changed files with 11146 additions and 1207 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
dist/
node_modules/
.wrangler/
.dev.vars

View File

@@ -1,27 +1,59 @@
# SproutBlog
Cloudflare Worker + D1 的极简博客
Cloudflare Worker + D1 + 静态资源Vite/React SPA。Worker **仅处理 `/api/*`**,前端构建产物输出到 `dist/`,由 `[assets]` 提供;`wrangler deploy` 一次部署 API + 前端
## 功能
- 公共博客
- Markdown 渲染
- 简单后台 CRUD
- 手机和电脑自适应
- 公共博客React + React Router
- Markdownmarked + KaTeX + highlight.js + Mermaid按需动态加载
- 简单后台 CRUDCookie / Basic Auth 与原先一致)
- 评论cwd-widget
- 手机与桌面适配(沿用原样式)
## 初始化
1. 创建 D1 数据库
`wrangler d1 create sproutblog`
2. 把生成的 `database_id` 填到 `wrangler.toml`
3. 应用迁移:
`wrangler d1 migrations apply sproutblog --remote`
4. 本地运行:
`npm run dev`
1. 创建 D1`wrangler d1 create sproutblog`,把 `database_id` 写入 `wrangler.toml`
2. 应用迁移:`wrangler d1 migrations apply sproutblog --remote`
3. 安装依赖:`npm install`
## 可选后台密码
## 开发
如果要给 `/admin` 加密码,设置 `ADMIN_PASSWORD`
- 一体化(会先 `vite build` 再同时拉起 Worker 与前端开发服务器)
- `npm run dev`
- Worker默认 `http://127.0.0.1:8787`(读取 `dist` 静态资源)
- 前端:`http://127.0.0.1:5173``/api` 代理到 `8787`
- 仅 Worker需已有 `dist``npm run dev:worker`
- 仅前端 + API 代理:`npm run dev:client`
- 修改 React 后若要在 `8787` 上看到最新静态文件,请再执行 `npm run build`
`wrangler secret put ADMIN_PASSWORD`
## 部署
```bash
npm run deploy
```
等同 `npm run build && wrangler deploy`
## 环境变量
- `SITE_TITLE` / `SITE_TITLE_EN` / `CWD_API_BASE`:可在 `wrangler.toml``[vars]` 中配置。
- `ADMIN_PASSWORD`:生产环境建议使用 `wrangler secret put ADMIN_PASSWORD`,勿提交仓库。
- 前端可选:`VITE_CWD_SITE_ID`(构建时注入;否则以 `/api/config` 中的 `CWD_SITE_ID` 为准)。
## API 说明(节选)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/config` | 站点公开配置 |
| POST | `/api/admin/session` | 设置后台 Cookiebody: `{ token }` |
| GET | `/api/posts` | 列表;未登录仅返回已发布文章 |
| GET | `/api/posts/:idOrSlug?bump=1` | 详情;`bump=1` 且匿名访问已发布文章时增加浏览量 |
| POST/PUT/DELETE | `/api/posts` … | 写操作需鉴权(与原先一致) |
## 目录结构(节选)
- `src/worker` — Worker 入口
- `src/api` — 路由与 HTTP 处理
- `src/db` — D1 访问
- `client/` — Vite + React 源码,构建至 `dist/`
- `migrations/` — D1 SQL

32
client/index.html Normal file
View File

@@ -0,0 +1,32 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>萌芽小窝</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css"
crossorigin="anonymous"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

BIN
client/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

BIN
client/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

25
client/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { ConfigProvider } from "./ConfigContext";
import { Layout } from "./Layout";
import { Home } from "./pages/Home";
import { PostPage } from "./pages/PostPage";
import { AdminDashboard, AdminEditor } from "./pages/Admin";
export function App() {
return (
<BrowserRouter>
<ConfigProvider>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="post/:slug" element={<PostPage />} />
<Route path="admin" element={<AdminDashboard />} />
<Route path="admin/new" element={<AdminEditor />} />
<Route path="admin/edit/:id" element={<AdminEditor />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</ConfigProvider>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,51 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode
} from "react";
import type { SiteConfig } from "./api";
import { fetchConfig } from "./api";
const Ctx = createContext<SiteConfig | null>(null);
export function ConfigProvider({ children }: { children: ReactNode }) {
const [cfg, setCfg] = useState<SiteConfig | null>(null);
const load = useCallback(async () => {
try {
setCfg(await fetchConfig());
} catch {
setCfg({
siteTitle: "萌芽小窝",
siteTitleEn: "SproutBlog",
cwdApiBase: "https://cwd.api.smyhub.com"
});
}
}, []);
useEffect(() => {
load();
}, [load]);
const value = useMemo(() => cfg, [cfg]);
if (!value) {
return (
<main style={{ padding: 24 }}>
<p></p>
</main>
);
}
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSiteConfig(): SiteConfig {
const v = useContext(Ctx);
if (!v) throw new Error("useSiteConfig");
return v;
}

40
client/src/Layout.tsx Normal file
View File

@@ -0,0 +1,40 @@
import { Link, Outlet } from "react-router-dom";
import { useSiteConfig } from "./ConfigContext";
import { AdminGate } from "./components/AdminGate";
import { GeoFooter } from "./components/GeoFooter";
export function Layout() {
const { siteTitle, siteTitleEn } = useSiteConfig();
return (
<>
<main>
<header className="site-header">
<div className="site-brand">
<AdminGate>
<img
className="site-logo"
src="/logo.png"
alt=""
width={40}
height={40}
decoding="async"
/>
</AdminGate>
<div className="site-titles">
<Link to="/" className="site-title-link">
{siteTitle}
</Link>
<span className="site-title-en">{siteTitleEn}</span>
</div>
</div>
</header>
<Outlet />
</main>
<footer className="site-footer">
<p>- @2026-</p>
<GeoFooter />
</footer>
</>
);
}

123
client/src/api.ts Normal file
View File

@@ -0,0 +1,123 @@
const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? "";
function apiUrl(path: string): string {
return API_BASE ? `${API_BASE}${path}` : path;
}
export type SiteConfig = {
siteTitle: string;
siteTitleEn: string;
cwdApiBase: string;
cwdSiteId?: string;
};
export type Post = {
id: number;
title: string;
slug: string;
excerpt: string;
content: string;
published: number;
created_at: string;
updated_at: string;
view_count?: number;
};
export async function fetchConfig(): Promise<SiteConfig> {
const r = await fetch(apiUrl("/api/config"));
const j = (await r.json()) as {
ok?: boolean;
config?: SiteConfig;
};
if (!j.ok || !j.config) throw new Error("config");
const viteId = import.meta.env.VITE_CWD_SITE_ID;
if (viteId && !j.config.cwdSiteId) {
j.config.cwdSiteId = viteId;
}
return j.config;
}
export async function fetchPosts(
q?: string,
opts?: { credentials?: RequestCredentials }
): Promise<Post[]> {
const u = new URL(apiUrl("/api/posts"), window.location.origin);
if (q) u.searchParams.set("q", q);
const r = await fetch(u.toString(), {
credentials: opts?.credentials ?? "same-origin"
});
const j = (await r.json()) as { ok?: boolean; posts?: Post[] };
if (!r.ok || !j.ok) throw new Error("posts");
return j.posts || [];
}
export async function fetchPost(
idOrSlug: string,
bump: boolean,
opts?: { credentials?: RequestCredentials }
): Promise<Post> {
const u = new URL(apiUrl(`/api/posts/${encodeURIComponent(idOrSlug)}`), window.location.origin);
if (bump) u.searchParams.set("bump", "1");
const r = await fetch(u.toString(), {
credentials: opts?.credentials ?? "same-origin"
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "post");
return j.post;
}
export async function postJson<T>(
path: string,
body: unknown,
opts?: { method?: string }
): Promise<T> {
const r = await fetch(apiUrl(path), {
method: opts?.method || "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
return (await r.json()) as T;
}
export async function adminLogin(token: string): Promise<boolean> {
const j = await postJson<{ ok?: boolean }>("/api/admin/session", { token });
return !!j.ok;
}
export async function savePost(
data: Record<string, unknown>,
id?: number | null
): Promise<Post> {
if (id) {
const r = await fetch(apiUrl(`/api/posts/${id}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(data)
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
return j.post;
}
const { id: _omit, ...body } = data;
void _omit;
const r = await fetch(apiUrl("/api/posts"), {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(body)
});
const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string };
if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save");
return j.post;
}
export async function deletePostApi(id: number): Promise<void> {
const r = await fetch(apiUrl(`/api/posts/${id}`), {
method: "DELETE",
credentials: "include"
});
const j = (await r.json()) as { ok?: boolean };
if (!r.ok || !j.ok) throw new Error("delete");
}

View File

@@ -0,0 +1,78 @@
import { useCallback, useState, type ReactNode } from "react";
import { useNavigate } from "react-router-dom";
import { adminLogin } from "../api";
export function AdminGate({ children }: { children: ReactNode }) {
const [n, setN] = useState(0);
const [open, setOpen] = useState(false);
const [token, setToken] = useState("");
const navigate = useNavigate();
const onLogoClick = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
const next = n + 1;
if (next >= 5) {
setN(0);
setOpen(true);
return;
}
setN(next);
window.setTimeout(() => setN(0), 4000);
},
[n]
);
const submit = useCallback(async () => {
const ok = await adminLogin(token);
if (ok) {
setOpen(false);
setToken("");
navigate("/admin");
return;
}
alert("Token 无效");
}, [token, navigate]);
return (
<>
<button
type="button"
className="admin-gate-logo-btn"
onClick={onLogoClick}
aria-label="站点标志"
>
{children}
</button>
<div id="admin-gate" className="admin-gate" hidden={!open}>
<div className="admin-gate-panel">
<input
id="admin-token"
type="password"
autoComplete="off"
placeholder="Token"
aria-label="Token"
value={token}
onChange={(e) => setToken(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
/>
<button type="button" id="admin-token-go" onClick={submit}>
</button>
<button
type="button"
id="admin-token-cancel"
onClick={() => setOpen(false)}
>
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,134 @@
import { useEffect } from "react";
import { useSiteConfig } from "../ConfigContext";
declare global {
interface Window {
CWDComments?: new (opts: {
el: string;
apiBaseUrl: string;
postSlug: string;
lang?: string;
siteId?: string;
}) => { mount: () => void };
__cwdLikeStatusQueryPatched?: boolean;
}
}
/** 修正 cwd-widget 点赞 GET 使用完整 URL 导致计数为 0 的问题(沿用旧脚本逻辑) */
function patchCwdLikeGetQuery(apiBaseNorm: string, postSlug: string): void {
if (window.__cwdLikeStatusQueryPatched) return;
window.__cwdLikeStatusQueryPatched = true;
const base = apiBaseNorm;
const orig = window.fetch.bind(window);
window.fetch = function fetchPatched(input: RequestInfo | URL, init?: RequestInit) {
let url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: input.url;
let method = "GET";
if (init?.method) method = String(init.method).toUpperCase();
else if (typeof Request !== "undefined" && input instanceof Request) {
method = String(input.method || "GET").toUpperCase();
}
if (
url &&
url.indexOf(base) === 0 &&
url.indexOf("/api/like?") !== -1 &&
method === "GET"
) {
try {
const u = new URL(url);
const ps = u.searchParams.get("post_slug") || "";
if (ps.indexOf("://") !== -1) {
u.searchParams.set("post_slug", postSlug);
if (typeof input === "string") {
input = u.toString();
} else if (typeof Request !== "undefined" && input instanceof Request) {
input = new Request(u.toString(), input);
} else if (input instanceof URL) {
input = u;
}
}
} catch {
/* ignore */
}
}
return orig(input as RequestInfo, init);
};
}
export function CwdComments({ postSlug }: { postSlug: string }) {
const { cwdApiBase, cwdSiteId } = useSiteConfig();
useEffect(() => {
const apiNorm = (cwdApiBase || "").replace(/\/$/, "");
patchCwdLikeGetQuery(apiNorm, postSlug);
function loadScript(src: string): Promise<void> {
return new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = src;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error(src));
document.head.appendChild(s);
});
}
let cancelled = false;
const el = document.getElementById("comments");
if (el) el.innerHTML = "";
(async () => {
try {
if (!window.CWDComments) {
await loadScript(
"https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js"
);
}
if (cancelled || typeof window.CWDComments === "undefined") return;
const opts: {
el: string;
apiBaseUrl: string;
postSlug: string;
lang?: string;
siteId?: string;
} = {
el: "#comments",
apiBaseUrl: cwdApiBase.replace(/\/$/, ""),
postSlug,
lang: "zh-CN"
};
if (cwdSiteId) opts.siteId = cwdSiteId;
new window.CWDComments(opts).mount();
} catch {
const node = document.getElementById("comments");
if (node) node.textContent = "评论插件加载失败。";
}
})();
return () => {
cancelled = true;
const node = document.getElementById("comments");
if (node) node.innerHTML = "";
};
}, [postSlug, cwdApiBase, cwdSiteId]);
return (
<section className="post-comments" aria-labelledby="comments-heading">
<h2 id="comments-heading" className="post-comments__title">
</h2>
<div
id="comments"
data-api-base={cwdApiBase}
data-post-slug={postSlug}
data-lang="zh-CN"
{...(cwdSiteId ? { "data-site-id": cwdSiteId } : {})}
/>
</section>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useState } from "react";
const GEO_API = "https://geoip.api.smyhub.com/api";
function formatGeo(g: Record<string, string> | null): string {
if (!g || typeof g !== "object") return "";
const parts: string[] = [];
if (g.countryName) parts.push(g.countryName);
if (g.regionName) parts.push(g.regionName);
if (g.cityName) parts.push(g.cityName);
return parts.join(" ");
}
export function GeoFooter() {
const [text, setText] = useState("正在获取访问信息…");
useEffect(() => {
fetch(GEO_API, { credentials: "omit", cache: "no-store" })
.then((r) => {
if (!r.ok) throw new Error("http " + r.status);
return r.json() as Promise<{ ip?: string; geo?: Record<string, string> }>;
})
.then((data) => {
const ip = data && data.ip ? String(data.ip) : "—";
let loc = formatGeo(data.geo || null);
if (!loc) loc = "未知位置";
setText(`您的访问 IP${ip} · ${loc}`);
})
.catch(() => setText("无法获取访问位置信息"));
}, []);
return (
<p className="site-footer-geo" id="site-footer-geo" aria-live="polite">
{text}
</p>
);
}

View File

@@ -0,0 +1,37 @@
import { useEffect, useRef } from "react";
import { renderMarkdownInto } from "../lib/markdown";
import { initPostToc, teardownPostTocUi } from "../lib/postToc";
export function MarkdownBody({ markdown }: { markdown: string }) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
teardownPostTocUi();
if (!markdown.trim()) {
el.innerHTML = "";
return;
}
el.classList.add("markdown-body", "markdown-pending");
el.innerHTML = '<p class="markdown-loading">加载中…</p>';
let cancelled = false;
(async () => {
try {
await renderMarkdownInto(el, markdown);
if (!cancelled) initPostToc(el);
} catch {
if (!cancelled) {
el.innerHTML = "<p>渲染失败。</p>";
el.classList.remove("markdown-pending");
}
}
})();
return () => {
cancelled = true;
teardownPostTocUi();
};
}, [markdown]);
return <div ref={ref} className="markdown-body" />;
}

View File

@@ -0,0 +1,73 @@
import { marked } from "marked";
import markedKatex from "marked-katex-extension";
import hljs from "highlight.js";
marked.use(
markedKatex({
throwOnError: false,
nonStandard: true
})
);
marked.setOptions({ gfm: true, breaks: true });
function decodeHtmlEntities(str: string): string {
return String(str)
.replace(/&nbsp;/g, "\u00a0")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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: string): string {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function mermaidFencedToDiv(html: string): string {
return html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi,
(_, inner: string) => {
const raw = decodeHtmlEntities(inner);
return `<div class="mermaid">${escapeHtml(raw)}</div>`;
}
);
}
/** 将 Markdown 渲染进元素(含 KaTeX、高亮、Mermaid */
export async function renderMarkdownInto(
el: HTMLElement,
markdown: string
): Promise<void> {
let html = marked.parse(markdown, { async: false }) as string;
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 as HTMLElement);
} catch {
/* ignore */
}
});
const mNodes = el.querySelectorAll(".mermaid");
if (mNodes.length) {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({ startOnLoad: false, theme: "neutral" });
await mermaid.run({ nodes: [...mNodes] as HTMLElement[] });
}
}

246
client/src/lib/postToc.ts Normal file
View File

@@ -0,0 +1,246 @@
/**
* 文章目录(由旧 post-toc.js 迁移)
*/
const HEADING_SEL = "h2, h3, h4";
function headingText(h: Element): string {
return (h.textContent || "").replace(/\s+/g, " ").trim();
}
function makeHeadingIds(headings: Element[]): void {
const used = new Set<string>();
headings.forEach((h) => {
if (h.id && !used.has(h.id)) {
used.add(h.id);
return;
}
const raw = headingText(h);
let base =
raw
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9\u2e80-\u9fff-]/gi, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "") || "section";
let id = base;
let n = 0;
while (used.has(id) || document.getElementById(id)) id = `${base}-${++n}`;
h.id = id;
used.add(id);
});
}
function buildTocOl(headings: Element[]) {
const ol = document.createElement("ol");
ol.className = "toc-tree";
const links: HTMLAnchorElement[] = [];
const path: { level: number; li: HTMLLIElement }[] = [];
for (const h of headings) {
const level = parseInt(h.tagName[1], 10);
if (level < 2 || level > 6) continue;
const li = document.createElement("li");
li.className = "toc-item";
const a = document.createElement("a");
a.className = "toc-link";
a.href = "#" + h.id;
a.textContent = headingText(h);
a.dataset.targetId = h.id;
li.appendChild(a);
links.push(a);
while (path.length && path[path.length - 1].level >= level) path.pop();
if (path.length === 0) {
ol.appendChild(li);
} else {
const parentLi = path[path.length - 1].li;
let sub = parentLi.querySelector(":scope > ol.toc-sub");
if (!sub) {
sub = document.createElement("ol");
sub.className = "toc-sub";
parentLi.appendChild(sub);
}
sub.appendChild(li);
}
path.push({ level, li });
}
return { ol, links };
}
function setActive(
linkGroups: HTMLAnchorElement[][],
activeId: string | null
): void {
for (const links of linkGroups) {
for (const a of links) {
const on = a.dataset.targetId === activeId;
a.classList.toggle("toc-link--active", !!on);
}
}
}
function bindScrollSpy(
headings: Element[],
linkGroups: HTMLAnchorElement[][],
desktopAside: HTMLElement | null,
mobileFab: HTMLElement | null,
scope: Element | null | undefined
): void {
const OFFSET = 100;
function update() {
const inArticle = scope ? scope.getBoundingClientRect().bottom > 120 : true;
if (desktopAside)
desktopAside.classList.toggle("post-toc--hidden", !inArticle);
if (mobileFab) mobileFab.classList.toggle("toc-fab--hidden", !inArticle);
const y = window.scrollY + OFFSET;
let current: string | null = headings[0]?.id || null;
for (const h of headings) {
if (h.getBoundingClientRect().top + window.scrollY <= y)
current = h.id || null;
}
if (current) setActive(linkGroups, current);
}
let ticking = false;
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
update();
ticking = false;
});
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });
update();
}
function bindTocLinks(
container: HTMLElement,
onNavigate?: (() => void) | null
): void {
container.addEventListener("click", (e) => {
const t = e.target as HTMLElement | null;
const a = t?.closest("a.toc-link") as HTMLAnchorElement | null;
if (!a) return;
const id = a.getAttribute("href")?.slice(1);
if (!id) return;
const target = document.getElementById(id);
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
history.replaceState(null, "", "#" + id);
onNavigate?.();
}
});
}
function initDesktop(
aside: HTMLElement,
nav: HTMLElement,
headings: Element[]
): HTMLAnchorElement[] {
const { ol, links } = buildTocOl(headings);
nav.innerHTML = "";
nav.appendChild(ol);
aside.hidden = false;
bindTocLinks(nav, null);
return links;
}
function initMobile(headings: Element[]) {
if (document.getElementById("toc-fab")) return { links: [] as HTMLAnchorElement[], fab: null as HTMLElement | null };
const { ol, links } = buildTocOl(headings);
const panel = document.createElement("div");
panel.id = "toc-panel";
panel.setAttribute("aria-label", "文章目录");
panel.setAttribute("role", "navigation");
const panelTitle = document.createElement("p");
panelTitle.className = "toc-panel__title";
panelTitle.textContent = "目录";
panel.appendChild(panelTitle);
panel.appendChild(ol);
document.body.appendChild(panel);
const fab = document.createElement("button");
fab.id = "toc-fab";
fab.type = "button";
fab.setAttribute("aria-label", "打开文章目录");
fab.setAttribute("aria-controls", "toc-panel");
fab.setAttribute("aria-expanded", "false");
fab.innerHTML = `
<span class="toc-fab__icon" aria-hidden="true">
<span></span><span></span><span></span>
</span>`;
document.body.appendChild(fab);
function openPanel() {
panel.classList.add("toc-panel--open");
fab.setAttribute("aria-expanded", "true");
fab.setAttribute("aria-label", "关闭文章目录");
fab.classList.add("toc-fab--open");
}
function closePanel() {
panel.classList.remove("toc-panel--open");
fab.setAttribute("aria-expanded", "false");
fab.setAttribute("aria-label", "打开文章目录");
fab.classList.remove("toc-fab--open");
}
fab.addEventListener("click", () => {
panel.classList.contains("toc-panel--open") ? closePanel() : openPanel();
});
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") closePanel();
});
bindTocLinks(panel, closePanel);
return { links, fab };
}
export function teardownPostTocUi(): void {
document.getElementById("toc-fab")?.remove();
document.getElementById("toc-panel")?.remove();
}
export function initPostToc(markdownBody: HTMLElement | null): void {
const aside = document.getElementById("post-toc");
const nav = document.getElementById("post-toc-nav");
if (!markdownBody) return;
const headings = [...markdownBody.querySelectorAll(HEADING_SEL)];
if (headings.length === 0) {
if (aside) aside.hidden = true;
return;
}
makeHeadingIds(headings);
const scope = markdownBody.closest(".post-with-toc");
let desktopLinks: HTMLAnchorElement[] = [];
if (aside && nav) {
desktopLinks = initDesktop(aside, nav, headings);
}
const { links: mobileLinks, fab: mobileElem } = initMobile(headings);
const fab = mobileElem || document.getElementById("toc-fab");
bindScrollSpy(headings, [desktopLinks, mobileLinks], aside, fab, scope);
if (location.hash.length > 1) {
const id = decodeURIComponent(location.hash.slice(1));
if (headings.some((h) => h.id === id)) {
setActive([desktopLinks, mobileLinks], id);
}
}
}

View File

@@ -1,37 +1,4 @@
export function cleanText(value) {
return String(value ?? "").trim();
}
export function isTruthy(value) {
return value === true || value === "true" || value === "1" || value === 1 || value === "on" || value === "yes";
}
export function escapeHtml(value) {
return String(value ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export function escapeAttr(value) {
return escapeHtml(value).replace(/`/g, "&#96;");
}
export function formatDateTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return escapeHtml(value);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
export function stripMarkdown(text) {
export function stripMarkdown(text: unknown): string {
return String(text ?? "")
.replace(/```[\s\S]*?```/g, " ")
.replace(/\$\$[\s\S]*?\$\$/g, " ")
@@ -47,6 +14,14 @@ export function stripMarkdown(text) {
.trim();
}
export function b64EncodeUtf8(str) {
return btoa(unescape(encodeURIComponent(str)));
export function formatDateTime(value: unknown): string {
if (!value) return "";
const date = new Date(String(value));
if (Number.isNaN(date.getTime())) return String(value);
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
const hh = String(date.getHours()).padStart(2, "0");
const mi = String(date.getMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}

11
client/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./styles/app.css";
import "./styles/markdown.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);

234
client/src/pages/Admin.tsx Normal file
View File

@@ -0,0 +1,234 @@
import { FormEvent, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import {
deletePostApi,
fetchPost,
fetchPosts,
savePost,
type Post
} from "../api";
import { useSiteConfig } from "../ConfigContext";
export function AdminDashboard() {
const { siteTitle } = useSiteConfig();
const [q, setQ] = useState("");
const [inputQ, setInputQ] = useState("");
const [posts, setPosts] = useState<Post[]>([]);
useEffect(() => {
document.title = `后台 - ${siteTitle}`;
}, [siteTitle]);
useEffect(() => {
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
return () => clearTimeout(id);
}, [inputQ]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const list = await fetchPosts(q || undefined, {
credentials: "include"
});
if (!cancelled) setPosts(list);
} catch {
if (!cancelled) setPosts([]);
}
})();
return () => {
cancelled = true;
};
}, [q]);
return (
<>
<p>
<Link to="/"></Link>
</p>
<h1></h1>
<p>
<Link to="/admin/new"></Link>
</p>
<section>
<h2></h2>
<form
onSubmit={(e) => e.preventDefault()}
>
<p>
<label>
<input value={inputQ} onChange={(e) => setInputQ(e.target.value)} />
</label>
</p>
</form>
{posts.length ? (
posts.map((post) => (
<article key={post.id}>
<h3>{post.title}</h3>
<p>
ID: {post.id} | Slug: {post.slug} |{" "}
{post.published ? "已发布" : "草稿"}
</p>
<div>
<Link to={`/admin/edit/${post.id}`}></Link>
<DeleteButton postId={post.id} onDone={() => setPosts((p) => p.filter((x) => x.id !== post.id))} />
</div>
</article>
))
) : (
<p></p>
)}
</section>
</>
);
}
function DeleteButton({
postId,
onDone
}: {
postId: number;
onDone: () => void;
}) {
return (
<form
style={{ display: "inline", marginLeft: 8 }}
onSubmit={async (e) => {
e.preventDefault();
if (!confirm("确定删除?")) return;
try {
await deletePostApi(postId);
onDone();
} catch {
alert("删除失败");
}
}}
>
<button type="submit"></button>
</form>
);
}
export function AdminEditor() {
const { id: idParam } = useParams();
const [search] = useSearchParams();
const navigate = useNavigate();
const { siteTitle } = useSiteConfig();
const id = idParam ? Number(idParam) : Number(search.get("id") || "") || null;
const [title, setTitle] = useState("");
const [slug, setSlug] = useState("");
const [excerpt, setExcerpt] = useState("");
const [content, setContent] = useState("");
const [published, setPublished] = useState(true);
useEffect(() => {
document.title = `后台 - ${siteTitle}`;
}, [siteTitle]);
useEffect(() => {
if (!id) {
setTitle("");
setSlug("");
setExcerpt("");
setContent("");
setPublished(true);
return;
}
let cancelled = false;
(async () => {
try {
const post = await fetchPost(String(id), false, {
credentials: "include"
});
if (cancelled) return;
setTitle(post.title);
setSlug(post.slug);
setExcerpt(post.excerpt || "");
setContent(post.content || "");
setPublished(!!post.published);
} catch {
if (!cancelled) alert("加载文章失败");
}
})();
return () => {
cancelled = true;
};
}, [id]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
try {
const payload = {
title,
slug,
excerpt,
content,
published: published ? 1 : 0,
id: id || undefined
};
const saved = await savePost(payload, id);
navigate(`/admin/edit/${saved.id}?saved=1`, { replace: true });
} catch (er) {
alert(er instanceof Error ? er.message : "保存失败");
}
}
return (
<>
<p>
<Link to="/admin"></Link>
</p>
<h1>{id ? `编辑 #${id}` : "新建文章"}</h1>
<form onSubmit={onSubmit}>
<p>
<label>
<br />
<input value={title} onChange={(e) => setTitle(e.target.value)} required />
</label>
</p>
<p>
<label>
Slug
<br />
<input value={slug} onChange={(e) => setSlug(e.target.value)} />
</label>
</p>
<p>
<label>
<br />
<input value={excerpt} onChange={(e) => setExcerpt(e.target.value)} />
</label>
</p>
<p>
<label>
<br />
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
required
rows={18}
style={{ width: "100%", maxWidth: "100%" }}
/>
</label>
</p>
<p>
<label>
<input
type="checkbox"
checked={published}
onChange={(e) => setPublished(e.target.checked)}
/>{" "}
</label>
</p>
<p>
<button type="submit"></button>
</p>
</form>
</>
);
}

95
client/src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { fetchPosts, type Post } from "../api";
import { useSiteConfig } from "../ConfigContext";
import { formatDateTime, stripMarkdown } from "../lib/text";
export function Home() {
const { siteTitle } = useSiteConfig();
const [inputQ, setInputQ] = useState("");
const [q, setQ] = useState("");
const [posts, setPosts] = useState<Post[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
document.title = siteTitle;
}, [siteTitle]);
useEffect(() => {
const id = window.setTimeout(() => setQ(inputQ.trim()), 300);
return () => clearTimeout(id);
}, [inputQ]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setErr(null);
const list = await fetchPosts(q || undefined);
if (!cancelled) setPosts(list);
} catch {
if (!cancelled) setErr("加载失败");
}
})();
return () => {
cancelled = true;
};
}, [q]);
return (
<>
<form
className="home-search"
onSubmit={(e) => e.preventDefault()}
style={{ marginBottom: 16 }}
>
<label>
{" "}
<input
name="q"
value={inputQ}
onChange={(e) => setInputQ(e.target.value)}
placeholder="标题 / 正文"
/>
</label>
</form>
{q ? (
<p>
<strong>{q}</strong>
</p>
) : null}
{err ? <p>{err}</p> : null}
{posts.length ? (
posts.map((post) => {
const excerpt =
post.excerpt || stripMarkdown(post.content).slice(0, 180);
const views = Number(post.view_count) || 0;
return (
<article key={post.id} className="post-card">
<h2>
<Link to={`/post/${encodeURIComponent(post.slug)}`}>
{post.title}
</Link>
</h2>
<p className="post-excerpt">
{excerpt}
{excerpt.length >= 180 ? "…" : ""}
</p>
<p className="post-meta">
<span className="post-card-time">
{formatDateTime(post.created_at)}
</span>
<span className="post-card-time">
{formatDateTime(post.updated_at)}
</span>
<span className="post-card-views"> {views}</span>
</p>
</article>
);
})
) : (
<p></p>
)}
</>
);
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { fetchPost } from "../api";
import { useSiteConfig } from "../ConfigContext";
import { CwdComments } from "../components/CwdComments";
import { MarkdownBody } from "../components/MarkdownBody";
import { formatDateTime } from "../lib/text";
export function PostPage() {
const { slug = "" } = useParams();
const { siteTitle } = useSiteConfig();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [updatedAt, setUpdatedAt] = useState("");
const [views, setViews] = useState(0);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setErr(null);
const post = await fetchPost(decodeURIComponent(slug), true);
if (cancelled) return;
setTitle(post.title);
setContent(post.content || "");
setUpdatedAt(post.updated_at);
setViews(Number(post.view_count) || 0);
document.title = `${post.title} - ${siteTitle}`;
} catch {
if (!cancelled) {
setErr("文章不存在或加载失败");
document.title = `404 - ${siteTitle}`;
}
}
})();
return () => {
cancelled = true;
};
}, [slug, siteTitle]);
if (err) {
return (
<>
<p>
<Link to="/"></Link>
</p>
<h1>404</h1>
<p>{err}</p>
</>
);
}
return (
<>
<p>
<Link to="/"></Link>
</p>
<div className="post-with-toc">
<div className="post-with-toc__main">
<article>
<h1>{title}</h1>
<p className="post-detail-meta">
<span className="post-detail-time">{formatDateTime(updatedAt)}</span>
<span className="post-detail-views"> {views}</span>
</p>
<MarkdownBody markdown={content} />
</article>
</div>
<aside
className="post-toc"
id="post-toc"
aria-label="文章目录"
hidden
>
<div className="post-toc__inner">
<p className="post-toc__title"></p>
<nav className="post-toc__nav" id="post-toc-nav" />
</div>
</aside>
</div>
<CwdComments key={slug} postSlug={decodeURIComponent(slug)} />
</>
);
}

612
client/src/styles/app.css Normal file
View File

@@ -0,0 +1,612 @@
:root {
--page-max: 920px;
--page-pad-x: 24px;
--page-pad-y: 24px;
--page-gap: 24px;
--text: #2a1f0e;
--muted: #6b5a42;
--line: #d9cdb8;
/* 笔记纸底色与格线 */
--paper-bg: #fdf8ec;
--paper-rule: rgba(160, 140, 100, 0.18);
--paper-grid: rgba(160, 140, 100, 0.10);
--paper-rule-spacing: 28px;
/**
* 有网Inter拉丁+ Noto Sans SC补中文外链见 render 中 Google Fonts
* 无网/外链失败:不加载 webfont 时,浏览器跳过未解析名称,用后续系统无衬线
*/
--font-sans: "Inter", "Noto Sans SC", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Segoe UI", system-ui, sans-serif;
/* 行内/块 <code> 等;无 Source Code Pro 时走系统等宽 */
--font-mono: "Source Code Pro", Menlo, Monaco, Consolas, "Courier New", monospace;
}
html {
margin: 0;
padding: 0;
background: var(--paper-bg);
color: var(--text);
font-family: var(--font-sans);
scroll-behavior: smooth;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
padding: 0;
color: var(--text);
font-family: var(--font-sans);
line-height: 1.7;
overflow-x: hidden;
/* 笔记纸纹理:竖向 + 横向格线,横线更淡 */
background-color: var(--paper-bg);
background-image:
repeating-linear-gradient(
90deg,
transparent,
transparent calc(var(--paper-rule-spacing) - 1px),
var(--paper-grid) calc(var(--paper-rule-spacing) - 1px),
var(--paper-grid) var(--paper-rule-spacing)
),
repeating-linear-gradient(
180deg,
transparent,
transparent calc(var(--paper-rule-spacing) - 1px),
rgba(160, 140, 100, 0.07) calc(var(--paper-rule-spacing) - 1px),
rgba(160, 140, 100, 0.07) var(--paper-rule-spacing)
);
}
code,
kbd,
samp {
font-family: var(--font-mono);
}
main {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: var(--page-pad-y) var(--page-pad-x) 32px;
box-sizing: border-box;
}
.site-footer {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: 20px var(--page-pad-x) 28px;
box-sizing: border-box;
border-top: 1px solid var(--line);
text-align: center;
font-size: 0.88rem;
color: var(--muted);
}
.site-footer p {
margin: 0;
line-height: 1.6;
}
.site-footer p.site-footer-geo {
margin-top: 10px;
font-size: 0.82rem;
color: var(--muted);
word-break: break-word;
}
header.site-header {
margin-bottom: var(--page-gap);
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.site-brand {
display: flex;
align-items: center;
gap: 12px;
}
.site-titles {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.site-title-link {
text-decoration: none;
color: inherit;
font-weight: 700;
font-size: 1.25rem;
line-height: 1.2;
}
.site-title-en {
font-size: 0.82rem;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.02em;
}
.site-logo {
flex-shrink: 0;
width: 40px;
height: 40px;
object-fit: contain;
cursor: pointer;
/* 让 logo 白底融入纸张背景 */
mix-blend-mode: multiply;
}
.admin-gate {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
box-sizing: border-box;
}
.admin-gate[hidden] {
display: none !important;
}
.admin-gate-panel {
background: var(--paper-bg);
padding: 16px 18px;
border-radius: 6px;
border: 1px solid #c8b99a;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: min(100%, 420px);
box-sizing: border-box;
}
.admin-gate-panel input[type="password"] {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
h1,
h2,
h3 {
line-height: 1.3;
margin: 0 0 12px;
word-break: break-word;
}
article,
section {
margin: 0 0 24px;
}
p {
margin: 0 0 12px;
word-break: break-word;
}
a {
color: inherit;
}
input,
textarea,
button {
font: inherit;
max-width: 100%;
}
input[type="text"],
input[type="password"],
textarea {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
textarea {
min-height: 320px;
resize: vertical;
}
pre {
font-family: var(--font-mono);
font-size: 0.92em;
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
img {
max-width: 100%;
height: auto;
}
hr {
border: 0;
border-top: 1px solid #d4c5a8;
margin: 24px 0;
}
.post-card {
padding-bottom: 16px;
border-bottom: 1px solid #e4d9c4;
}
.post-card:last-of-type {
padding-bottom: 0;
border-bottom: 0;
}
.post-card .post-excerpt {
margin: 0 0 10px;
}
.post-card .post-meta {
font-size: 0.9em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-meta {
font-size: 0.95em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-views {
color: #666;
}
.post-comments {
margin-top: 36px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
.post-comments__title {
font-size: 1.35rem;
margin: 0 0 16px;
}
form {
margin: 0;
}
@media (max-width: 1024px) {
:root {
--page-max: 840px;
--page-pad-x: 20px;
--page-pad-y: 20px;
--page-gap: 20px;
}
}
@media (max-width: 768px) {
:root {
--page-max: 100%;
--page-pad-x: 16px;
--page-pad-y: 16px;
--page-gap: 18px;
}
.site-logo {
width: 36px;
height: 36px;
}
.site-title-link {
font-size: 1.1rem;
}
.post-card .post-meta,
.post-detail-meta {
gap: 6px 12px;
}
}
@media (max-width: 480px) {
:root {
--page-pad-x: 14px;
--page-pad-y: 14px;
--page-gap: 16px;
}
body {
line-height: 1.68;
}
header.site-header {
padding-bottom: 12px;
}
textarea {
min-height: 240px;
}
.site-brand {
gap: 10px;
align-items: flex-start;
}
.site-logo {
width: 32px;
height: 32px;
}
.site-title-link {
font-size: 1rem;
}
.post-card .post-meta,
.post-detail-meta {
font-size: 0.82em;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
row-gap: 4px;
}
.post-card .post-meta span,
.post-detail-meta span {
white-space: nowrap;
}
.admin-gate-panel {
padding: 14px 14px;
}
}
/* ----- 文章页布局 ----- */
main:has(.post-with-toc),
body:has(.post-with-toc) .site-footer {
width: min(100%, 920px);
max-width: 920px;
}
.post-with-toc {
display: block;
width: 100%;
}
.post-with-toc__main {
min-width: 0;
width: min(100%, 720px);
margin: 0 auto;
}
/* ═══ 桌面端目录≥1181px═══════════════════════════════════ */
.post-toc {
position: fixed;
top: 96px;
right: max(14px, calc((100vw - 1400px) / 2));
z-index: 20;
width: 260px;
max-width: min(30vw, 260px);
transition: opacity 0.18s ease, visibility 0.18s ease;
}
.post-toc--hidden {
visibility: hidden;
opacity: 0;
pointer-events: none;
}
.post-toc__inner {
box-sizing: border-box;
max-height: calc(100vh - 128px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.post-toc__title {
margin: 0 0 10px;
font-size: 0.75rem;
font-weight: 500;
color: #888;
}
.post-toc__nav {
position: relative;
margin: 0;
border-left: 1px solid #d4c5a8;
padding-left: 14px;
}
/* 桌面≤1180px 完全隐藏 aside */
@media (max-width: 1180px) {
.post-toc {
display: none;
}
main:has(.post-with-toc),
body:has(.post-with-toc) .site-footer {
width: min(100%, var(--page-max));
max-width: var(--page-max);
}
}
/* ═══ 公共目录列表样式(桌面 & 移动端共用类名)══════════════ */
.toc-tree,
.toc-sub {
list-style: none;
margin: 0;
padding: 0;
}
.toc-sub {
margin-top: 2px;
margin-bottom: 2px;
padding-left: 10px;
}
.toc-item {
margin: 0;
}
.toc-link {
display: block;
position: relative;
padding: 5px 0 6px;
font-size: 0.88rem;
line-height: 1.5;
text-decoration: none;
color: #777;
}
.toc-sub .toc-link {
color: #999;
font-size: 0.82rem;
}
.toc-link:hover,
.toc-sub .toc-link:hover {
color: #111;
}
.toc-link--active {
color: #111;
font-weight: 500;
}
/* 桌面目录:当前项左侧指示条 */
.post-toc__nav .toc-link--active::before {
content: "";
position: absolute;
left: -16px;
top: 0.15em;
bottom: 0.15em;
width: 2px;
background: #111;
border-radius: 0;
}
/* ═══ 移动端 FABJS append 到 body≤1180px 显示)═════════ */
#toc-fab {
display: none;
}
@media (max-width: 1180px) {
#toc-fab {
display: inline-flex;
position: fixed;
right: 20px;
bottom: calc(22px + env(safe-area-inset-bottom));
z-index: 50;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
border: 1px solid rgba(160, 130, 80, 0.25);
border-radius: 50%;
background: rgba(253, 248, 236, 0.97);
box-shadow: 0 6px 20px rgba(100, 80, 40, 0.16);
color: #333;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.18s, visibility 0.18s, transform 0.18s;
}
#toc-fab.toc-fab--hidden {
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: scale(0.85);
}
/* 汉堡线 */
.toc-fab__icon {
display: flex;
flex-direction: column;
gap: 4px;
width: 18px;
transition: gap 0.18s;
}
.toc-fab__icon span {
display: block;
height: 2px;
width: 18px;
background: currentColor;
border-radius: 2px;
transition: transform 0.18s, opacity 0.18s, width 0.18s;
transform-origin: center;
}
/* 打开状态 → X 形 */
#toc-fab.toc-fab--open .toc-fab__icon {
gap: 0;
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(1) {
transform: translateY(2px) rotate(45deg);
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(2) {
opacity: 0;
width: 0;
}
#toc-fab.toc-fab--open .toc-fab__icon span:nth-child(3) {
transform: translateY(-2px) rotate(-45deg);
}
}
/* ═══ 移动端弹出面板JS append 到 body≤1180px 显示)══════ */
#toc-panel {
display: none;
}
@media (max-width: 1180px) {
#toc-panel {
display: block;
position: fixed;
right: 16px;
bottom: calc(76px + env(safe-area-inset-bottom));
z-index: 49;
width: min(300px, calc(100vw - 32px));
max-height: min(60vh, 440px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
box-sizing: border-box;
padding: 14px 16px 16px;
background: rgba(253, 248, 236, 0.98);
border: 1px solid rgba(160, 130, 80, 0.2);
border-radius: 12px;
box-shadow: 0 12px 36px rgba(100, 80, 40, 0.18);
/* 默认隐藏 */
opacity: 0;
visibility: hidden;
pointer-events: none;
transform: translateY(10px) scale(0.97);
transform-origin: bottom right;
transition: opacity 0.16s ease, visibility 0.16s ease, transform 0.16s ease;
}
#toc-panel.toc-panel--open {
opacity: 1;
visibility: visible;
pointer-events: auto;
transform: none;
}
.toc-panel__title {
margin: 0 0 8px;
font-size: 0.75rem;
font-weight: 500;
color: #888;
}
/* 移动端面板:当前项左侧指示条 */
#toc-panel .toc-link--active::before {
content: "";
position: absolute;
left: -14px;
top: 0.15em;
bottom: 0.15em;
width: 2px;
background: #111;
border-radius: 0;
}
#toc-panel .toc-tree {
border-left: 1px solid #d4c5a8;
padding-left: 14px;
}
#toc-panel .toc-link {
font-size: 0.9rem;
padding: 6px 0 7px;
}
#toc-panel .toc-sub .toc-link {
font-size: 0.84rem;
}
}
/* React可点击 Logo 用于后台入口 */
.admin-gate-logo-btn {
all: unset;
display: flex;
align-items: center;
cursor: pointer;
flex-shrink: 0;
}
/* ═══ cwd-widget 评论区覆盖:融入纸张主题 ═══════════════════ */
#comments > div,
#comments .cwd-widget,
#comments [class*="card"],
#comments [class*="form-wrap"],
#comments [class*="comment-form"],
#comments [class*="CommentForm"],
#comments form {
background: var(--paper-bg) !important;
border-color: var(--line) !important;
box-shadow: 0 2px 12px rgba(100, 80, 40, 0.1) !important;
}
#comments input[type="text"],
#comments input[type="email"],
#comments input[type="url"],
#comments textarea {
background: rgba(255, 252, 240, 0.8) !important;
border-color: #c8b99a !important;
color: var(--text) !important;
}
#comments input[type="text"]:focus,
#comments input[type="email"]:focus,
#comments input[type="url"]:focus,
#comments textarea:focus {
border-color: #a08060 !important;
outline: none !important;
box-shadow: 0 0 0 2px rgba(160, 128, 96, 0.2) !important;
}
#comments [class*="comment-item"],
#comments [class*="CommentItem"],
#comments [class*="comment-card"] {
background: rgba(255, 252, 240, 0.6) !important;
border-color: var(--line) !important;
}
/* 兜底widget 内所有白色背景块 */
#comments > div[style*="background"],
#comments > div > div[style*="background:#fff"],
#comments > div > div[style*="background: #fff"],
#comments > div > div[style*="background:white"],
#comments > div > div[style*="background: white"] {
background: var(--paper-bg) !important;
}

View File

@@ -23,11 +23,13 @@
.markdown-body h2 {
font-size: 1.5rem;
margin: 22px 0 10px;
scroll-margin-top: 1.25em;
}
.markdown-body h3 {
font-size: 1.25rem;
margin: 18px 0 8px;
scroll-margin-top: 1.15em;
}
.markdown-body h4,
@@ -35,6 +37,7 @@
.markdown-body h6 {
font-size: 1.05rem;
margin: 16px 0 8px;
scroll-margin-top: 1.05em;
}
.markdown-body p {
@@ -83,7 +86,7 @@
}
.markdown-body code {
font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
font-family: var(--font-mono);
font-size: 0.92em;
}

9
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_CWD_SITE_ID?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

24
client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["vite.config.ts"]
}

33
client/vite.config.ts Normal file
View File

@@ -0,0 +1,33 @@
import path from "node:path";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
const root = path.resolve(__dirname);
export default defineConfig(({ mode }) => {
const isDesktop = mode === "desktop";
return {
plugins: [react()],
root,
publicDir: path.join(root, "public"),
define: {
"import.meta.env.VITE_API_BASE": JSON.stringify(
isDesktop ? "https://blog.smyhub.com" : ""
)
},
build: {
outDir: path.resolve(__dirname, isDesktop ? "../dist-desktop" : "../dist"),
emptyOutDir: true,
chunkSizeWarningLimit: 1200
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://127.0.0.1:8787",
changeOrigin: true
}
}
}
};
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{a0 as ln,a1 as an,a2 as H,a3 as q,a4 as B,a5 as un,a6 as y,a7 as tn,a8 as L,a9 as _,aa as rn,ab as o,ac as on,ad as sn,ae as fn}from"./mermaid.core-DD7RPEfx.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*L(on(0,T*T*R-P*P)),j=(P*g-m*S)/R,z=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=yn,I=B(0),D=null,v=gn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-un,c=A.apply(this,arguments)-un,F=rn(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(F>tn-y)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>y&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(rn(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>y){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>y?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>y?(M*=t?1:-1,m+=M,g-=M):(S=0,m=g=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>y){var U=s*H(g),V=s*q(g),X=u*H(R),Y=u*q(R),E;if(F<an)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(fn((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?x>y?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,g,!t)):a.moveTo(J,K),!(u>y)||!(P>y)?a.lineTo(N,Q):p>y?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-an/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{aq as o,ar as n}from"./mermaid.core-DD7RPEfx.js";const t=(r,a)=>o.lang.round(n.parse(r)[a]);export{t as c};

View File

@@ -0,0 +1 @@
import{_ as l}from"./mermaid.core-DD7RPEfx.js";function m(e,c){var i,t,o;e.accDescr&&((i=c.setAccDescription)==null||i.call(c,e.accDescr)),e.accTitle&&((t=c.setAccTitle)==null||t.call(c,e.accTitle)),e.title&&((o=c.setDiagramTitle)==null||o.call(c,e.title))}l(m,"populateCommonDb");export{m as p};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{_ as a,d as o}from"./mermaid.core-DD7RPEfx.js";var d=a((t,e)=>{let n;return e==="sandbox"&&(n=o("#i"+t)),(e==="sandbox"?o(n.nodes()[0].contentDocument.body):o("body")).select(`[id="${t}"]`)},"getDiagramElement");export{d as g};

View File

@@ -0,0 +1 @@
import{_ as a,e as w,l as x}from"./mermaid.core-DD7RPEfx.js";var d=a((e,t,i,o)=>{e.attr("class",i);const{width:r,height:h,x:n,y:c}=u(e,t);w(e,h,r,o);const s=l(n,c,r,h,t);e.attr("viewBox",s),x.debug(`viewBox configured: ${s} with padding: ${t}`)},"setupViewPortForSVG"),u=a((e,t)=>{var o;const i=((o=e.node())==null?void 0:o.getBBox())||{width:0,height:0,x:0,y:0};return{width:i.width+t*2,height:i.height+t*2,x:i.x,y:i.y}},"calculateDimensionsWithPadding"),l=a((e,t,i,o,r)=>`${e-r} ${t-r} ${i} ${o}`,"createViewBox");export{d as s};

View File

@@ -0,0 +1,15 @@
import{_ as e}from"./mermaid.core-DD7RPEfx.js";var l=e(()=>`
/* Font Awesome icon styling - consolidated */
.label-icon {
display: inline-block;
height: 1em;
overflow: visible;
vertical-align: -0.125em;
}
.node .label-icon path {
fill: currentColor;
stroke: revert;
stroke-width: revert;
}
`,"getIconStyles");export{l as g};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{_ as s}from"./mermaid.core-DD7RPEfx.js";var t,e=(t=class{constructor(i){this.init=i,this.records=this.init()}reset(){this.records=this.init()}},s(t,"ImperativeState"),t);export{e as I};

View File

@@ -0,0 +1 @@
import{_ as i,d as l,U as d,j as o}from"./mermaid.core-DD7RPEfx.js";var x=i((r,t)=>{const e=r.append("rect");if(e.attr("x",t.x),e.attr("y",t.y),e.attr("fill",t.fill),e.attr("stroke",t.stroke),e.attr("width",t.width),e.attr("height",t.height),t.name&&e.attr("name",t.name),t.rx&&e.attr("rx",t.rx),t.ry&&e.attr("ry",t.ry),t.attrs!==void 0)for(const s in t.attrs)e.attr(s,t.attrs[s]);return t.class&&e.attr("class",t.class),e},"drawRect"),p=i((r,t)=>{const e={x:t.startx,y:t.starty,width:t.stopx-t.startx,height:t.stopy-t.starty,fill:t.fill,stroke:t.stroke,class:"rect"};x(r,e).lower()},"drawBackgroundRect"),y=i((r,t)=>{const e=t.text.replace(d," "),s=r.append("text");s.attr("x",t.x),s.attr("y",t.y),s.attr("class","legend"),s.style("text-anchor",t.anchor),t.class&&s.attr("class",t.class);const a=s.append("tspan");return a.attr("x",t.x+t.textMargin*2),a.text(e),s},"drawText"),m=i((r,t,e,s)=>{const a=r.append("image");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",n)},"drawImage"),g=i((r,t,e,s)=>{const a=r.append("use");a.attr("x",t),a.attr("y",e);const n=o.sanitizeUrl(s);a.attr("xlink:href",`#${n}`)},"drawEmbeddedImage"),h=i(()=>({x:0,y:0,width:100,height:100,fill:"#EDF2AE",stroke:"#666",anchor:"start",rx:0,ry:0}),"getNoteRect"),f=i(()=>({x:0,y:0,width:100,height:100,"text-anchor":"start",style:"#666",textMargin:0,rx:0,ry:0,tspan:!0}),"getTextObj"),w=i(()=>{let r=l(".mermaidTooltip");return r.empty()&&(r=l("body").append("div").attr("class","mermaidTooltip").style("opacity",0).style("position","absolute").style("text-align","center").style("max-width","200px").style("padding","2px").style("font-size","12px").style("background","#ffffde").style("border","1px solid #333").style("border-radius","2px").style("pointer-events","none").style("z-index","100")),r},"createTooltip");export{p as a,f as b,w as c,x as d,g as e,m as f,h as g,y as h};

View File

@@ -0,0 +1 @@
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-D8YpqJC4.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-FMBD7UC4-TatyaWoN.js";import"./chunk-YZCP3GAM-Blce-T0j.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};

View File

@@ -0,0 +1 @@
import{s as a,c as s,a as e,C as t}from"./chunk-4TB4RGXK-D8YpqJC4.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-FMBD7UC4-TatyaWoN.js";import"./chunk-YZCP3GAM-Blce-T0j.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var n={parser:e,get db(){return new t},renderer:s,styles:a,init:i(r=>{r.class||(r.class={}),r.class.arrowMarkerAbsolute=r.arrowMarkerAbsolute},"init")};export{n as diagram};

View File

@@ -0,0 +1 @@
import{b as r}from"./graph-C_fMmQpx.js";var e=4;function a(o){return r(o,e)}export{a as c};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
function J(n){return Math.abs(n=Math.round(n))>=1e21?n.toLocaleString("en").replace(/,/g,""):n.toString(10)}function j(n,t){if(!isFinite(n)||n===0)return null;var e=(n=t?n.toExponential(t-1):n.toExponential()).indexOf("e"),i=n.slice(0,e);return[i.length>1?i[0]+i.slice(2):i,+n.slice(e+1)]}function K(n){return n=j(Math.abs(n)),n?n[1]:NaN}function Q(n,t){return function(e,i){for(var o=e.length,a=[],c=0,h=n[0],M=0;o>0&&h>0&&(M+h+1>i&&(h=Math.max(1,i-M)),a.push(e.substring(o-=h,o+h)),!((M+=h+1)>i));)h=n[c=(c+1)%n.length];return a.reverse().join(t)}}function V(n){return function(t){return t.replace(/[0-9]/g,function(e){return n[+e]})}}var W=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function $(n){if(!(t=W.exec(n)))throw new Error("invalid format: "+n);var t;return new L({fill:t[1],align:t[2],sign:t[3],symbol:t[4],zero:t[5],width:t[6],comma:t[7],precision:t[8]&&t[8].slice(1),trim:t[9],type:t[10]})}$.prototype=L.prototype;function L(n){this.fill=n.fill===void 0?" ":n.fill+"",this.align=n.align===void 0?">":n.align+"",this.sign=n.sign===void 0?"-":n.sign+"",this.symbol=n.symbol===void 0?"":n.symbol+"",this.zero=!!n.zero,this.width=n.width===void 0?void 0:+n.width,this.comma=!!n.comma,this.precision=n.precision===void 0?void 0:+n.precision,this.trim=!!n.trim,this.type=n.type===void 0?"":n.type+""}L.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(this.width===void 0?"":Math.max(1,this.width|0))+(this.comma?",":"")+(this.precision===void 0?"":"."+Math.max(0,this.precision|0))+(this.trim?"~":"")+this.type};function _(n){n:for(var t=n.length,e=1,i=-1,o;e<t;++e)switch(n[e]){case".":i=o=e;break;case"0":i===0&&(i=e),o=e;break;default:if(!+n[e])break n;i>0&&(i=0);break}return i>0?n.slice(0,i)+n.slice(o+1):n}var N;function v(n,t){var e=j(n,t);if(!e)return N=void 0,n.toPrecision(t);var i=e[0],o=e[1],a=o-(N=Math.max(-8,Math.min(8,Math.floor(o/3)))*3)+1,c=i.length;return a===c?i:a>c?i+new Array(a-c+1).join("0"):a>0?i.slice(0,a)+"."+i.slice(a):"0."+new Array(1-a).join("0")+j(n,Math.max(0,t+a-1))[0]}function X(n,t){var e=j(n,t);if(!e)return n+"";var i=e[0],o=e[1];return o<0?"0."+new Array(-o).join("0")+i:i.length>o+1?i.slice(0,o+1)+"."+i.slice(o+1):i+new Array(o-i.length+2).join("0")}const O={"%":(n,t)=>(n*100).toFixed(t),b:n=>Math.round(n).toString(2),c:n=>n+"",d:J,e:(n,t)=>n.toExponential(t),f:(n,t)=>n.toFixed(t),g:(n,t)=>n.toPrecision(t),o:n=>Math.round(n).toString(8),p:(n,t)=>X(n*100,t),r:X,s:v,X:n=>Math.round(n).toString(16).toUpperCase(),x:n=>Math.round(n).toString(16)};function R(n){return n}var U=Array.prototype.map,Y=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function nn(n){var t=n.grouping===void 0||n.thousands===void 0?R:Q(U.call(n.grouping,Number),n.thousands+""),e=n.currency===void 0?"":n.currency[0]+"",i=n.currency===void 0?"":n.currency[1]+"",o=n.decimal===void 0?".":n.decimal+"",a=n.numerals===void 0?R:V(U.call(n.numerals,String)),c=n.percent===void 0?"%":n.percent+"",h=n.minus===void 0?"":n.minus+"",M=n.nan===void 0?"NaN":n.nan+"";function T(f,g){f=$(f);var b=f.fill,p=f.align,m=f.sign,w=f.symbol,S=f.zero,E=f.width,F=f.comma,y=f.precision,C=f.trim,d=f.type;d==="n"?(F=!0,d="g"):O[d]||(y===void 0&&(y=12),C=!0,d="g"),(S||b==="0"&&p==="=")&&(S=!0,b="0",p="=");var q=(g&&g.prefix!==void 0?g.prefix:"")+(w==="$"?e:w==="#"&&/[boxX]/.test(d)?"0"+d.toLowerCase():""),B=(w==="$"?i:/[%p]/.test(d)?c:"")+(g&&g.suffix!==void 0?g.suffix:""),D=O[d],H=/[defgprs%]/.test(d);y=y===void 0?6:/[gprs]/.test(d)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y));function G(r){var l=q,u=B,x,I,k;if(d==="c")u=D(r)+u,r="";else{r=+r;var P=r<0||1/r<0;if(r=isNaN(r)?M:D(Math.abs(r),y),C&&(r=_(r)),P&&+r==0&&m!=="+"&&(P=!1),l=(P?m==="("?m:h:m==="-"||m==="("?"":m)+l,u=(d==="s"&&!isNaN(r)&&N!==void 0?Y[8+N/3]:"")+u+(P&&m==="("?")":""),H){for(x=-1,I=r.length;++x<I;)if(k=r.charCodeAt(x),48>k||k>57){u=(k===46?o+r.slice(x+1):r.slice(x))+u,r=r.slice(0,x);break}}}F&&!S&&(r=t(r,1/0));var z=l.length+r.length+u.length,s=z<E?new Array(E-z+1).join(b):"";switch(F&&S&&(r=t(s+r,s.length?E-u.length:1/0),s=""),p){case"<":r=l+r+u+s;break;case"=":r=l+s+r+u;break;case"^":r=s.slice(0,z=s.length>>1)+l+r+u+s.slice(z);break;default:r=s+l+r+u;break}return a(r)}return G.toString=function(){return f+""},G}function Z(f,g){var b=Math.max(-8,Math.min(8,Math.floor(K(g)/3)))*3,p=Math.pow(10,-b),m=T((f=$(f),f.type="f",f),{suffix:Y[8+b/3]});return function(w){return m(p*w)}}return{format:T,formatPrefix:Z}}var A,tn,rn;en({thousands:",",grouping:[3],currency:["$",""]});function en(n){return A=nn(n),tn=A.format,rn=A.formatPrefix,A}export{rn as a,tn as b,K as e,$ as f};

View File

@@ -0,0 +1,10 @@
import{p as x,b as f,s as C,q as B,g as T,a as y,_ as s,F as u,l as k,I as V,e as _,D,G as N,z as S}from"./mermaid.core-DD7RPEfx.js";import{p as I}from"./chunk-4BX2VUAB-CI7enyPF.js";import{I as $}from"./chunk-QZHKN3VN-BFI20CEN.js";import{p as z}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var d=new $(()=>({cnt:1,stack:[{id:0,level:-1,name:"/",children:[]}]})),A=s(()=>{d.reset(),S()},"clear"),X=s(()=>d.records.stack[0],"getRoot"),H=s(()=>d.records.cnt,"getCount"),L=N.treeView,R=s(()=>u(L,D().treeView),"getConfig"),W=s((e,t)=>{for(;e<=d.records.stack[d.records.stack.length-1].level;)d.records.stack.pop();const a={id:d.records.cnt++,level:e,name:t,children:[]};d.records.stack[d.records.stack.length-1].children.push(a),d.records.stack.push(a)},"addNode"),E={clear:A,addNode:W,getRoot:X,getCount:H,getConfig:R,getAccTitle:y,getAccDescription:T,getDiagramTitle:B,setAccDescription:C,setAccTitle:f,setDiagramTitle:x},w=E,F=s(e=>{I(e,w),e.nodes.map(t=>w.addNode(t.indent?parseInt(t.indent):0,t.name))},"populate"),M={parse:s(async e=>{const t=await z("treeView",e);k.debug(t),F(t)},"parse")},Y=s((e,t,a,n,o)=>{const c=n.append("text").text(a.name).attr("dominant-baseline","middle").attr("class","treeView-node-label"),{height:p,width:r}=c.node().getBBox(),l=p+o.paddingY*2,i=r+o.paddingX*2;c.attr("x",e+o.paddingX),c.attr("y",t+l/2),a.BBox={x:e,y:t,width:i,height:l}},"positionLabel"),b=s((e,t,a,n,o,c)=>e.append("line").attr("x1",t).attr("y1",a).attr("x2",n).attr("y2",o).attr("stroke-width",c).attr("class","treeView-node-line"),"positionLine"),q=s((e,t,a)=>{let n=0,o=0;const c=s((r,l,i,h)=>{const v=h*(i.rowIndent+i.paddingX);Y(v,n,l,r,i);const{height:g,width:m}=l.BBox;b(r,v-i.rowIndent,n+g/2,v,n+g/2,i.lineThickness),o=Math.max(o,v+m),n+=g},"drawNode"),p=s((r,l=0)=>{c(e,r,a,l),r.children.forEach(g=>{p(g,l+1)});const{x:i,y:h,height:v}=r.BBox;if(r.children.length){const{y:g,height:m}=r.children[r.children.length-1].BBox;b(e,i+a.paddingX,h+v,i+a.paddingX,g+m/2+a.lineThickness/2,a.lineThickness)}},"processNode");return p(t),{totalHeight:n,totalWidth:o}},"drawTree"),G=s((e,t,a,n)=>{k.debug(`Rendering treeView diagram
`+e);const o=n.db,c=o.getRoot(),p=o.getConfig(),r=V(t),l=r.append("g");l.attr("class","tree-view");const{totalHeight:i,totalWidth:h}=q(l,c,p);r.attr("viewBox",`-${p.lineThickness/2} 0 ${h} ${i}`),_(r,i,h,p.useMaxWidth)},"draw"),j={draw:G},J=j,K={labelFontSize:"16px",labelColor:"black",lineColor:"black"},O=s(({treeView:e})=>{const{labelFontSize:t,labelColor:a,lineColor:n}=u(K,e);return`
.treeView-node-label {
font-size: ${t};
fill: ${a};
}
.treeView-node-line {
stroke: ${n};
}
`},"styles"),P=O,se={db:w,renderer:J,parser:M,styles:P};export{se as diagram};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,43 @@
import{s as k,g as I,q as R,p as F,a as _,b as D,_ as l,I as E,z,F as y,G,D as C,l as P,K as W,e as B}from"./mermaid.core-DD7RPEfx.js";import{p as V}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as H}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var x={showLegend:!0,ticks:5,max:null,min:0,graticule:"circle"},w={axes:[],curves:[],options:x},m=structuredClone(w),j=G.radar,q=l(()=>y({...j,...C().radar}),"getConfig"),b=l(()=>m.axes,"getAxes"),K=l(()=>m.curves,"getCurves"),N=l(()=>m.options,"getOptions"),U=l(a=>{m.axes=a.map(t=>({name:t.name,label:t.label??t.name}))},"setAxes"),X=l(a=>{m.curves=a.map(t=>({name:t.name,label:t.label??t.name,entries:Y(t.entries)}))},"setCurves"),Y=l(a=>{if(a[0].axis==null)return a.map(e=>e.value);const t=b();if(t.length===0)throw new Error("Axes must be populated before curves for reference entries");return t.map(e=>{const r=a.find(s=>{var o;return((o=s.axis)==null?void 0:o.$refText)===e.name});if(r===void 0)throw new Error("Missing entry for axis "+e.label);return r.value})},"computeCurveEntries"),Z=l(a=>{var e,r,s,o,i;const t=a.reduce((n,c)=>(n[c.name]=c,n),{});m.options={showLegend:((e=t.showLegend)==null?void 0:e.value)??x.showLegend,ticks:((r=t.ticks)==null?void 0:r.value)??x.ticks,max:((s=t.max)==null?void 0:s.value)??x.max,min:((o=t.min)==null?void 0:o.value)??x.min,graticule:((i=t.graticule)==null?void 0:i.value)??x.graticule}},"setOptions"),J=l(()=>{z(),m=structuredClone(w)},"clear"),$={getAxes:b,getCurves:K,getOptions:N,setAxes:U,setCurves:X,setOptions:Z,getConfig:q,clear:J,setAccTitle:D,getAccTitle:_,setDiagramTitle:F,getDiagramTitle:R,getAccDescription:I,setAccDescription:k},Q=l(a=>{V(a,$);const{axes:t,curves:e,options:r}=a;$.setAxes(t),$.setCurves(e),$.setOptions(r)},"populate"),tt={parse:l(async a=>{const t=await H("radar",a);P.debug(t),Q(t)},"parse")},et=l((a,t,e,r)=>{const s=r.db,o=s.getAxes(),i=s.getCurves(),n=s.getOptions(),c=s.getConfig(),d=s.getDiagramTitle(),p=E(t),u=at(p,c),g=n.max??Math.max(...i.map(f=>Math.max(...f.entries))),h=n.min,v=Math.min(c.width,c.height)/2;rt(u,o,v,n.ticks,n.graticule),st(u,o,v,c),M(u,o,i,h,g,n.graticule,c),T(u,i,n.showLegend,c),u.append("text").attr("class","radarTitle").text(d).attr("x",0).attr("y",-c.height/2-c.marginTop)},"draw"),at=l((a,t)=>{const e=t.width+t.marginLeft+t.marginRight,r=t.height+t.marginTop+t.marginBottom,s={x:t.marginLeft+t.width/2,y:t.marginTop+t.height/2};return B(a,r,e,t.useMaxWidth??!0),a.attr("viewBox",`0 0 ${e} ${r}`),a.append("g").attr("transform",`translate(${s.x}, ${s.y})`)},"drawFrame"),rt=l((a,t,e,r,s)=>{if(s==="circle")for(let o=0;o<r;o++){const i=e*(o+1)/r;a.append("circle").attr("r",i).attr("class","radarGraticule")}else if(s==="polygon"){const o=t.length;for(let i=0;i<r;i++){const n=e*(i+1)/r,c=t.map((d,p)=>{const u=2*p*Math.PI/o-Math.PI/2,g=n*Math.cos(u),h=n*Math.sin(u);return`${g},${h}`}).join(" ");a.append("polygon").attr("points",c).attr("class","radarGraticule")}}},"drawGraticule"),st=l((a,t,e,r)=>{const s=t.length;for(let o=0;o<s;o++){const i=t[o].label,n=2*o*Math.PI/s-Math.PI/2;a.append("line").attr("x1",0).attr("y1",0).attr("x2",e*r.axisScaleFactor*Math.cos(n)).attr("y2",e*r.axisScaleFactor*Math.sin(n)).attr("class","radarAxisLine"),a.append("text").text(i).attr("x",e*r.axisLabelFactor*Math.cos(n)).attr("y",e*r.axisLabelFactor*Math.sin(n)).attr("class","radarAxisLabel")}},"drawAxes");function M(a,t,e,r,s,o,i){const n=t.length,c=Math.min(i.width,i.height)/2;e.forEach((d,p)=>{if(d.entries.length!==n)return;const u=d.entries.map((g,h)=>{const v=2*Math.PI*h/n-Math.PI/2,f=A(g,r,s,c),S=f*Math.cos(v),O=f*Math.sin(v);return{x:S,y:O}});o==="circle"?a.append("path").attr("d",L(u,i.curveTension)).attr("class",`radarCurve-${p}`):o==="polygon"&&a.append("polygon").attr("points",u.map(g=>`${g.x},${g.y}`).join(" ")).attr("class",`radarCurve-${p}`)})}l(M,"drawCurves");function A(a,t,e,r){const s=Math.min(Math.max(a,t),e);return r*(s-t)/(e-t)}l(A,"relativeRadius");function L(a,t){const e=a.length;let r=`M${a[0].x},${a[0].y}`;for(let s=0;s<e;s++){const o=a[(s-1+e)%e],i=a[s],n=a[(s+1)%e],c=a[(s+2)%e],d={x:i.x+(n.x-o.x)*t,y:i.y+(n.y-o.y)*t},p={x:n.x-(c.x-i.x)*t,y:n.y-(c.y-i.y)*t};r+=` C${d.x},${d.y} ${p.x},${p.y} ${n.x},${n.y}`}return`${r} Z`}l(L,"closedRoundCurve");function T(a,t,e,r){if(!e)return;const s=(r.width/2+r.marginRight)*3/4,o=-(r.height/2+r.marginTop)*3/4,i=20;t.forEach((n,c)=>{const d=a.append("g").attr("transform",`translate(${s}, ${o+c*i})`);d.append("rect").attr("width",12).attr("height",12).attr("class",`radarLegendBox-${c}`),d.append("text").attr("x",16).attr("y",0).attr("class","radarLegendText").text(n.label)})}l(T,"drawLegend");var nt={draw:et},ot=l((a,t)=>{let e="";for(let r=0;r<a.THEME_COLOR_LIMIT;r++){const s=a[`cScale${r}`];e+=`
.radarCurve-${r} {
color: ${s};
fill: ${s};
fill-opacity: ${t.curveOpacity};
stroke: ${s};
stroke-width: ${t.curveStrokeWidth};
}
.radarLegendBox-${r} {
fill: ${s};
fill-opacity: ${t.curveOpacity};
stroke: ${s};
}
`}return e},"genIndexStyles"),it=l(a=>{const t=W(),e=C(),r=y(t,e.themeVariables),s=y(r.radar,a);return{themeVariables:r,radarOptions:s}},"buildRadarStyleOptions"),lt=l(({radar:a}={})=>{const{themeVariables:t,radarOptions:e}=it(a);return`
.radarTitle {
font-size: ${t.fontSize};
color: ${t.titleColor};
dominant-baseline: hanging;
text-anchor: middle;
}
.radarAxisLine {
stroke: ${e.axisColor};
stroke-width: ${e.axisStrokeWidth};
}
.radarAxisLabel {
dominant-baseline: middle;
text-anchor: middle;
font-size: ${e.axisLabelFontSize}px;
color: ${e.axisColor};
}
.radarGraticule {
fill: ${e.graticuleColor};
fill-opacity: ${e.graticuleOpacity};
stroke: ${e.graticuleColor};
stroke-width: ${e.graticuleStrokeWidth};
}
.radarLegendText {
text-anchor: start;
font-size: ${e.legendFontSize}px;
dominant-baseline: hanging;
}
${ot(t,e)}
`},"styles"),xt={parser:tt,db:$,renderer:nt,styles:lt};export{xt as diagram};

View File

@@ -0,0 +1,24 @@
import{_ as b,F as m,I as B,e as C,l as w,b as S,a as D,p as T,q as z,g as F,s as P,G as E,D as A,z as W}from"./mermaid.core-DD7RPEfx.js";import{p as _}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as N}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var I=E.packet,u,v=(u=class{constructor(){this.packet=[],this.setAccTitle=S,this.getAccTitle=D,this.setDiagramTitle=T,this.getDiagramTitle=z,this.getAccDescription=F,this.setAccDescription=P}getConfig(){const t=m({...I,...A().packet});return t.showBits&&(t.paddingY+=10),t}getPacket(){return this.packet}pushWord(t){t.length>0&&this.packet.push(t)}clear(){W(),this.packet=[]}},b(u,"PacketDB"),u),L=1e4,M=b((e,t)=>{_(e,t);let o=-1,r=[],n=1;const{bitsPerRow:l}=t.getConfig();for(let{start:a,end:i,bits:d,label:c}of e.blocks){if(a!==void 0&&i!==void 0&&i<a)throw new Error(`Packet block ${a} - ${i} is invalid. End must be greater than start.`);if(a??(a=o+1),a!==o+1)throw new Error(`Packet block ${a} - ${i??a} is not contiguous. It should start from ${o+1}.`);if(d===0)throw new Error(`Packet block ${a} is invalid. Cannot have a zero bit field.`);for(i??(i=a+(d??1)-1),d??(d=i-a+1),o=i,w.debug(`Packet block ${a} - ${o} with label ${c}`);r.length<=l+1&&t.getPacket().length<L;){const[p,s]=Y({start:a,end:i,bits:d,label:c},n,l);if(r.push(p),p.end+1===n*l&&(t.pushWord(r),r=[],n++),!s)break;({start:a,end:i,bits:d,label:c}=s)}}t.pushWord(r)},"populate"),Y=b((e,t,o)=>{if(e.start===void 0)throw new Error("start should have been set during first phase");if(e.end===void 0)throw new Error("end should have been set during first phase");if(e.start>e.end)throw new Error(`Block start ${e.start} is greater than block end ${e.end}.`);if(e.end+1<=t*o)return[e,void 0];const r=t*o-1,n=t*o;return[{start:e.start,end:r,label:e.label,bits:r-e.start},{start:n,end:e.end,label:e.label,bits:e.end-n}]},"getNextFittingBlock"),x={parser:{yy:void 0},parse:b(async e=>{var r;const t=await N("packet",e),o=(r=x.parser)==null?void 0:r.yy;if(!(o instanceof v))throw new Error("parser.parser?.yy was not a PacketDB. This is due to a bug within Mermaid, please report this issue at https://github.com/mermaid-js/mermaid/issues.");w.debug(t),M(t,o)},"parse")},G=b((e,t,o,r)=>{const n=r.db,l=n.getConfig(),{rowHeight:a,paddingY:i,bitWidth:d,bitsPerRow:c}=l,p=n.getPacket(),s=n.getDiagramTitle(),h=a+i,g=h*(p.length+1)-(s?0:a),k=d*c+2,f=B(t);f.attr("viewBox",`0 0 ${k} ${g}`),C(f,g,k,l.useMaxWidth);for(const[y,$]of p.entries())O(f,$,y,l);f.append("text").text(s).attr("x",k/2).attr("y",g-h/2).attr("dominant-baseline","middle").attr("text-anchor","middle").attr("class","packetTitle")},"draw"),O=b((e,t,o,{rowHeight:r,paddingX:n,paddingY:l,bitWidth:a,bitsPerRow:i,showBits:d})=>{const c=e.append("g"),p=o*(r+l)+l;for(const s of t){const h=s.start%i*a+1,g=(s.end-s.start+1)*a-n;if(c.append("rect").attr("x",h).attr("y",p).attr("width",g).attr("height",r).attr("class","packetBlock"),c.append("text").attr("x",h+g/2).attr("y",p+r/2).attr("class","packetLabel").attr("dominant-baseline","middle").attr("text-anchor","middle").text(s.label),!d)continue;const k=s.end===s.start,f=p-2;c.append("text").attr("x",h+(k?g/2:0)).attr("y",f).attr("class","packetByte start").attr("dominant-baseline","auto").attr("text-anchor",k?"middle":"start").text(s.start),k||c.append("text").attr("x",h+g).attr("y",f).attr("class","packetByte end").attr("dominant-baseline","auto").attr("text-anchor","end").text(s.end)}},"drawWord"),j={draw:G},q={byteFontSize:"10px",startByteColor:"black",endByteColor:"black",labelColor:"black",labelFontSize:"12px",titleColor:"black",titleFontSize:"14px",blockStrokeColor:"black",blockStrokeWidth:"1",blockFillColor:"#efefef"},H=b(({packet:e}={})=>{const t=m(q,e);return`
.packetByte {
font-size: ${t.byteFontSize};
}
.packetByte.start {
fill: ${t.startByteColor};
}
.packetByte.end {
fill: ${t.endByteColor};
}
.packetLabel {
fill: ${t.labelColor};
font-size: ${t.labelFontSize};
}
.packetTitle {
fill: ${t.titleColor};
font-size: ${t.titleFontSize};
}
.packetBlock {
stroke: ${t.blockStrokeColor};
stroke-width: ${t.blockStrokeWidth};
fill: ${t.blockFillColor};
}
`},"styles"),V={parser:x,get db(){return new v},renderer:j,styles:H};export{V as diagram};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,2 @@
import{_ as a,l as s,I as n,e as i}from"./mermaid.core-DD7RPEfx.js";import{p}from"./wardley-RL74JXVD-BAks5CVw.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";var g={parse:a(async r=>{const e=await p("info",r);s.debug(e)},"parse")},v={version:"11.14.0"},d=a(()=>v.version,"getVersion"),m={getVersion:d},c=a((r,e,o)=>{s.debug(`rendering info diagram
`+r);const t=n(e);i(t,100,400,!0),t.append("g").append("text").attr("x",100).attr("y",40).attr("class","version").attr("font-size",32).style("text-anchor","middle").text(`v${o}`)},"draw"),l={draw:c},y={parser:g,db:m,renderer:l};export{y as diagram};

View File

@@ -0,0 +1 @@
function t(e,a){switch(arguments.length){case 0:break;case 1:this.range(e);break;default:this.range(a).domain(e);break}return this}export{t as i};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{b,a as m,c as d,d as h,i as l}from"./_baseUniq-D-yNBara.js";import{aG as g,aH as o,aI as p}from"./mermaid.core-DD7RPEfx.js";function L(a){var n=a==null?0:a.length;return n?b(a):[]}function v(a,n){var s=-1,t=g(a)?Array(a.length):[];return m(a,function(f,i,e){t[++s]=n(f,i,e)}),t}function M(a,n){var s=o(a)?h:v;return s(a,d(n))}function x(a,n){return a<n}function A(a,n,s){for(var t=-1,f=a.length;++t<f;){var i=a[t],e=n(i);if(e!=null&&(r===void 0?e===e&&!l(e):s(e,r)))var r=e,u=i}return u}function k(a){return a&&a.length?A(a,p,x):void 0}export{x as a,A as b,v as c,k as d,L as f,M as m};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{i as a}from"./init-Gi6I4Gst.js";class o extends Map{constructor(n,t=g){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:t}}),n!=null)for(const[r,s]of n)this.set(r,s)}get(n){return super.get(c(this,n))}has(n){return super.has(c(this,n))}set(n,t){return super.set(l(this,n),t)}delete(n){return super.delete(p(this,n))}}function c({_intern:e,_key:n},t){const r=n(t);return e.has(r)?e.get(r):t}function l({_intern:e,_key:n},t){const r=n(t);return e.has(r)?e.get(r):(e.set(r,t),t)}function p({_intern:e,_key:n},t){const r=n(t);return e.has(r)&&(t=e.get(r),e.delete(r)),t}function g(e){return e!==null&&typeof e=="object"?e.valueOf():e}const f=Symbol("implicit");function h(){var e=new o,n=[],t=[],r=f;function s(u){let i=e.get(u);if(i===void 0){if(r!==f)return r;e.set(u,i=n.push(u)-1)}return t[i%t.length]}return s.domain=function(u){if(!arguments.length)return n.slice();n=[],e=new o;for(const i of u)e.has(i)||e.set(i,n.push(i)-1);return s},s.range=function(u){return arguments.length?(t=Array.from(u),s):t.slice()},s.unknown=function(u){return arguments.length?(r=u,s):r},s.copy=function(){return h(n,t).unknown(r)},a.apply(s,arguments),s}export{h as o};

View File

@@ -0,0 +1,30 @@
import{a4 as S,a7 as R,aE as Q,g as Y,s as tt,a as et,b as at,q as rt,p as nt,_ as p,l as z,c as it,F as st,I as ot,N as lt,e as ct,z as ut,G as pt}from"./mermaid.core-DD7RPEfx.js";import{p as gt}from"./chunk-4BX2VUAB-CI7enyPF.js";import{p as dt}from"./wardley-RL74JXVD-BAks5CVw.js";import{d as _}from"./arc-CZ0PsMQW.js";import{o as ft}from"./ordinal-Cboi1Yqb.js";import"./index-jkhvCyNw.js";import"./min-CxVBSoSd.js";import"./_baseUniq-D-yNBara.js";import"./init-Gi6I4Gst.js";function ht(t,a){return a<t?-1:a>t?1:a>=t?0:NaN}function mt(t){return t}function vt(){var t=mt,a=ht,f=null,y=S(0),s=S(R),g=S(0);function o(e){var n,l=(e=Q(e)).length,d,h,v=0,c=new Array(l),i=new Array(l),x=+y.apply(this,arguments),w=Math.min(R,Math.max(-R,s.apply(this,arguments)-x)),m,D=Math.min(Math.abs(w)/l,g.apply(this,arguments)),$=D*(w<0?-1:1),u;for(n=0;n<l;++n)(u=i[c[n]=n]=+t(e[n],n,e))>0&&(v+=u);for(a!=null?c.sort(function(A,C){return a(i[A],i[C])}):f!=null&&c.sort(function(A,C){return f(e[A],e[C])}),n=0,h=v?(w-l*$)/v:0;n<l;++n,x=m)d=c[n],u=i[d],m=x+(u>0?u*h:0)+$,i[d]={data:e[d],index:n,value:u,startAngle:x,endAngle:m,padAngle:D};return i}return o.value=function(e){return arguments.length?(t=typeof e=="function"?e:S(+e),o):t},o.sortValues=function(e){return arguments.length?(a=e,f=null,o):a},o.sort=function(e){return arguments.length?(f=e,a=null,o):f},o.startAngle=function(e){return arguments.length?(y=typeof e=="function"?e:S(+e),o):y},o.endAngle=function(e){return arguments.length?(s=typeof e=="function"?e:S(+e),o):s},o.padAngle=function(e){return arguments.length?(g=typeof e=="function"?e:S(+e),o):g},o}var xt=pt.pie,F={sections:new Map,showData:!1},T=F.sections,W=F.showData,St=structuredClone(xt),yt=p(()=>structuredClone(St),"getConfig"),wt=p(()=>{T=new Map,W=F.showData,ut()},"clear"),At=p(({label:t,value:a})=>{if(a<0)throw new Error(`"${t}" has invalid value: ${a}. Negative values are not allowed in pie charts. All slice values must be >= 0.`);T.has(t)||(T.set(t,a),z.debug(`added new section: ${t}, with value: ${a}`))},"addSection"),Ct=p(()=>T,"getSections"),Dt=p(t=>{W=t},"setShowData"),$t=p(()=>W,"getShowData"),V={getConfig:yt,clear:wt,setDiagramTitle:nt,getDiagramTitle:rt,setAccTitle:at,getAccTitle:et,setAccDescription:tt,getAccDescription:Y,addSection:At,getSections:Ct,setShowData:Dt,getShowData:$t},Tt=p((t,a)=>{gt(t,a),a.setShowData(t.showData),t.sections.map(a.addSection)},"populateDb"),Et={parse:p(async t=>{const a=await dt("pie",t);z.debug(a),Tt(a,V)},"parse")},bt=p(t=>`
.pieCircle{
stroke: ${t.pieStrokeColor};
stroke-width : ${t.pieStrokeWidth};
opacity : ${t.pieOpacity};
}
.pieOuterCircle{
stroke: ${t.pieOuterStrokeColor};
stroke-width: ${t.pieOuterStrokeWidth};
fill: none;
}
.pieTitleText {
text-anchor: middle;
font-size: ${t.pieTitleTextSize};
fill: ${t.pieTitleTextColor};
font-family: ${t.fontFamily};
}
.slice {
font-family: ${t.fontFamily};
fill: ${t.pieSectionTextColor};
font-size:${t.pieSectionTextSize};
// fill: white;
}
.legend text {
fill: ${t.pieLegendTextColor};
font-family: ${t.fontFamily};
font-size: ${t.pieLegendTextSize};
}
`,"getStyles"),kt=bt,Mt=p(t=>{const a=[...t.values()].reduce((s,g)=>s+g,0),f=[...t.entries()].map(([s,g])=>({label:s,value:g})).filter(s=>s.value/a*100>=1);return vt().value(s=>s.value).sort(null)(f)},"createPieArcs"),Rt=p((t,a,f,y)=>{var O;z.debug(`rendering pie chart
`+t);const s=y.db,g=it(),o=st(s.getConfig(),g.pie),e=40,n=18,l=4,d=450,h=d,v=ot(a),c=v.append("g");c.attr("transform","translate("+h/2+","+d/2+")");const{themeVariables:i}=g;let[x]=lt(i.pieOuterStrokeWidth);x??(x=2);const w=o.textPosition,m=Math.min(h,d)/2-e,D=_().innerRadius(0).outerRadius(m),$=_().innerRadius(m*w).outerRadius(m*w);c.append("circle").attr("cx",0).attr("cy",0).attr("r",m+x/2).attr("class","pieOuterCircle");const u=s.getSections(),A=Mt(u),C=[i.pie1,i.pie2,i.pie3,i.pie4,i.pie5,i.pie6,i.pie7,i.pie8,i.pie9,i.pie10,i.pie11,i.pie12];let E=0;u.forEach(r=>{E+=r});const G=A.filter(r=>(r.data.value/E*100).toFixed(0)!=="0"),b=ft(C).domain([...u.keys()]);c.selectAll("mySlices").data(G).enter().append("path").attr("d",D).attr("fill",r=>b(r.data.label)).attr("class","pieCircle"),c.selectAll("mySlices").data(G).enter().append("text").text(r=>(r.data.value/E*100).toFixed(0)+"%").attr("transform",r=>"translate("+$.centroid(r)+")").style("text-anchor","middle").attr("class","slice");const U=c.append("text").text(s.getDiagramTitle()).attr("x",0).attr("y",-400/2).attr("class","pieTitleText"),N=[...u.entries()].map(([r,M])=>({label:r,value:M})),k=c.selectAll(".legend").data(N).enter().append("g").attr("class","legend").attr("transform",(r,M)=>{const P=n+l,H=P*N.length/2,J=12*n,K=M*P-H;return"translate("+J+","+K+")"});k.append("rect").attr("width",n).attr("height",n).style("fill",r=>b(r.label)).style("stroke",r=>b(r.label)),k.append("text").attr("x",n+l).attr("y",n-l).text(r=>s.getShowData()?`${r.label} [${r.value}]`:r.label);const j=Math.max(...k.selectAll("text").nodes().map(r=>(r==null?void 0:r.getBoundingClientRect().width)??0)),q=h+e+n+l+j,L=((O=U.node())==null?void 0:O.getBoundingClientRect().width)??0,X=h/2-L/2,Z=h/2+L/2,B=Math.min(0,X),I=Math.max(q,Z)-B;v.attr("viewBox",`${B} 0 ${I} ${d}`),ct(v,d,I,o.useMaxWidth)},"draw"),zt={draw:Rt},Vt={parser:Et,db:V,renderer:zt,styles:kt};export{Vt as diagram};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
import{s as e,b as r,a,S as s}from"./chunk-OYMX7WX6-Bgc90t5_.js";import{_ as i}from"./mermaid.core-DD7RPEfx.js";import"./chunk-55IACEB6-D6svk_zs.js";import"./chunk-EDXVE4YY-D2oMcFeR.js";import"./index-jkhvCyNw.js";var p={parser:a,get db(){return new s(2)},renderer:r,styles:e,init:i(t=>{t.state||(t.state={}),t.state.arrowMarkerAbsolute=t.arrowMarkerAbsolute},"init")};export{p as diagram};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

BIN
dist-desktop/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

33
dist-desktop/index.html Normal file
View File

@@ -0,0 +1,33 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
<title>萌芽小窝</title>
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&family=Source+Code+Pro:wght@400;500;600&display=swap"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css"
crossorigin="anonymous"
/>
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css"
crossorigin="anonymous"
/>
<script type="module" crossorigin src="/assets/index-jkhvCyNw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DCC3CA10.css">
</head>
<body>
<div id="root"></div>
</body>

BIN
dist-desktop/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

5025
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -3,8 +3,31 @@
"private": true,
"type": "module",
"scripts": {
"dev": "npx wrangler dev",
"deploy": "npx wrangler deploy",
"typecheck": "node --check worker.js"
"dev": "npm run build && concurrently -k \"npm:dev:worker\" \"npm:dev:client\"",
"dev:worker": "wrangler dev",
"dev:client": "vite --config client/vite.config.ts",
"build": "vite build --config client/vite.config.ts && vite build --config client/vite.config.ts --mode desktop",
"deploy": "npm run build && wrangler deploy",
"typecheck": "tsc -p tsconfig.worker.json && tsc -p client/tsconfig.json"
},
"dependencies": {
"highlight.js": "^11.11.1",
"katex": "^0.16.21",
"marked": "^15.0.7",
"marked-katex-extension": "^5.1.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"mermaid": "^11.4.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20250510.0",
"@types/react": "^19.0.2",
"@types/react-dom": "^19.0.2",
"@vitejs/plugin-react": "^4.3.4",
"concurrently": "^9.1.2",
"typescript": "^5.7.2",
"vite": "^6.0.7",
"wrangler": "^4.85.0"
}
}

View File

@@ -1,278 +0,0 @@
:root {
--page-max: 920px;
--page-pad-x: 24px;
--page-pad-y: 24px;
--page-gap: 24px;
--text: #111;
--muted: #555;
--line: #e8e8e8;
}
html {
margin: 0;
padding: 0;
background: #fff;
color: var(--text);
font-family: "FangSong", serif;
-webkit-text-size-adjust: 100%;
text-size-adjust: 100%;
}
body {
margin: 0;
padding: 0;
background: #fff;
color: var(--text);
font-family: "FangSong", serif;
line-height: 1.7;
overflow-x: hidden;
}
main {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: var(--page-pad-y) var(--page-pad-x) 32px;
box-sizing: border-box;
}
.site-footer {
width: min(100%, var(--page-max));
margin: 0 auto;
padding: 20px var(--page-pad-x) 28px;
box-sizing: border-box;
border-top: 1px solid var(--line);
text-align: center;
font-size: 0.88rem;
color: var(--muted);
}
.site-footer p {
margin: 0;
line-height: 1.6;
}
header.site-header {
margin-bottom: var(--page-gap);
padding-bottom: 16px;
border-bottom: 1px solid var(--line);
}
.site-brand {
display: flex;
align-items: center;
gap: 12px;
}
.site-titles {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.site-title-link {
text-decoration: none;
color: inherit;
font-weight: 700;
font-size: 1.25rem;
line-height: 1.2;
}
.site-title-en {
font-size: 0.82rem;
font-weight: 500;
color: var(--muted);
letter-spacing: 0.02em;
}
.site-logo {
flex-shrink: 0;
width: 40px;
height: 40px;
object-fit: contain;
cursor: pointer;
}
.admin-gate {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.35);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
padding: 16px;
box-sizing: border-box;
}
.admin-gate[hidden] {
display: none !important;
}
.admin-gate-panel {
background: #fff;
padding: 16px 18px;
border-radius: 6px;
border: 1px solid #ccc;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
width: min(100%, 420px);
box-sizing: border-box;
}
.admin-gate-panel input[type="password"] {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
h1,
h2,
h3 {
line-height: 1.3;
margin: 0 0 12px;
word-break: break-word;
}
article,
section {
margin: 0 0 24px;
}
p {
margin: 0 0 12px;
word-break: break-word;
}
a {
color: inherit;
}
input,
textarea,
button {
font: inherit;
max-width: 100%;
}
input[type="text"],
input[type="password"],
textarea {
width: 100%;
box-sizing: border-box;
min-width: 0;
}
textarea {
min-height: 320px;
resize: vertical;
}
pre {
white-space: pre-wrap;
word-break: break-word;
overflow-x: auto;
}
img {
max-width: 100%;
height: auto;
}
hr {
border: 0;
border-top: 1px solid #ddd;
margin: 24px 0;
}
.post-card {
padding-bottom: 16px;
border-bottom: 1px solid #efefef;
}
.post-card:last-of-type {
padding-bottom: 0;
border-bottom: 0;
}
.post-card .post-excerpt {
margin: 0 0 10px;
}
.post-card .post-meta {
font-size: 0.9em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-meta {
font-size: 0.95em;
color: #555;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
}
.post-detail-views {
color: #666;
}
.post-comments {
margin-top: 36px;
padding-top: 28px;
border-top: 1px solid var(--line);
}
.post-comments__title {
font-size: 1.35rem;
margin: 0 0 16px;
}
form {
margin: 0;
}
@media (max-width: 1024px) {
:root {
--page-max: 840px;
--page-pad-x: 20px;
--page-pad-y: 20px;
--page-gap: 20px;
}
}
@media (max-width: 768px) {
:root {
--page-max: 100%;
--page-pad-x: 16px;
--page-pad-y: 16px;
--page-gap: 18px;
}
.site-logo {
width: 36px;
height: 36px;
}
.site-title-link {
font-size: 1.1rem;
}
.post-card .post-meta,
.post-detail-meta {
gap: 6px 12px;
}
}
@media (max-width: 480px) {
:root {
--page-pad-x: 14px;
--page-pad-y: 14px;
--page-gap: 16px;
}
body {
line-height: 1.68;
}
header.site-header {
padding-bottom: 12px;
}
textarea {
min-height: 240px;
}
.site-brand {
gap: 10px;
align-items: flex-start;
}
.site-logo {
width: 32px;
height: 32px;
}
.site-title-link {
font-size: 1rem;
}
.post-card .post-meta,
.post-detail-meta {
font-size: 0.82em;
flex-direction: row;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 10px;
row-gap: 4px;
}
.post-card .post-meta span,
.post-detail-meta span {
white-space: nowrap;
}
.admin-gate-panel {
padding: 14px 14px;
}
}

View File

@@ -1,59 +0,0 @@
(function () {
var logo = document.querySelector(".site-logo");
var gate = document.getElementById("admin-gate");
var inp = document.getElementById("admin-token");
var btnGo = document.getElementById("admin-token-go");
var btnCancel = document.getElementById("admin-token-cancel");
if (!logo || !gate || !inp || !btnGo || !btnCancel) return;
var n = 0;
var resetTimer = null;
function showGate() {
gate.hidden = false;
inp.value = "";
inp.focus();
}
function hideGate() {
gate.hidden = true;
}
logo.addEventListener("click", function (e) {
e.preventDefault();
if (resetTimer) clearTimeout(resetTimer);
n += 1;
if (n >= 5) {
n = 0;
showGate();
return;
}
resetTimer = setTimeout(function () {
n = 0;
}, 4000);
});
btnCancel.addEventListener("click", hideGate);
function submit() {
fetch("/admin/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ token: inp.value })
})
.then(function (r) {
if (r.ok) {
hideGate();
location.href = "/admin";
return;
}
inp.select();
alert("Token 无效");
})
.catch(function () {
alert("网络错误");
});
}
btnGo.addEventListener("click", submit);
inp.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
});
})();

View File

@@ -1,78 +0,0 @@
(function () {
/**
* cwd-widget≤0.1.11)里 getLikeStatus 用 post_slug = 完整页面 URLpostUrl
* 而点赞写入 likePage 用的是 postSlug后端排行榜按 slug 汇总,导致前台一直显示 0。
* getPagePv 已写成 postUrl || postSlug点赞查询未对齐。这里把 GET /api/like 的
* post_slug 从绝对地址改回与评论区一致的 slug。
*/
function patchCwdLikeGetQuery(apiBaseNorm, postSlug) {
if (window.__cwdLikeStatusQueryPatched) return;
window.__cwdLikeStatusQueryPatched = true;
var base = apiBaseNorm;
var orig = window.fetch;
window.fetch = function (input, init) {
var url =
typeof input === "string"
? input
: input && typeof input.url === "string"
? input.url
: "";
var method = "GET";
if (init && init.method) method = String(init.method).toUpperCase();
else if (typeof Request !== "undefined" && input instanceof Request) {
method = String(input.method || "GET").toUpperCase();
}
if (
url &&
url.indexOf(base) === 0 &&
url.indexOf("/api/like?") !== -1 &&
method === "GET"
) {
try {
var u = new URL(url);
var ps = u.searchParams.get("post_slug") || "";
if (ps.indexOf("://") !== -1) {
u.searchParams.set("post_slug", postSlug);
if (typeof input === "string") {
input = u.toString();
} else if (typeof Request !== "undefined" && input instanceof Request) {
input = new Request(u.toString(), input);
}
}
} catch (e) {
/* ignore */
}
}
return orig.call(this, input, init);
};
}
function mount() {
var el = document.getElementById("comments");
if (!el || typeof CWDComments === "undefined") return;
var ds = el.dataset;
var apiBaseUrl = ds.apiBase;
var postSlug = ds.postSlug;
if (!apiBaseUrl || !postSlug) return;
patchCwdLikeGetQuery(apiBaseUrl.replace(/\/$/, ""), postSlug);
var opts = {
el: "#comments",
apiBaseUrl: apiBaseUrl,
postSlug: postSlug,
lang: ds.lang || "zh-CN"
};
if (ds.siteId) opts.siteId = ds.siteId;
new CWDComments(opts).mount();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", mount);
} else {
mount();
}
})();

View File

@@ -1,97 +0,0 @@
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
})
);
// breaks: true — 段内单换行渲染为 <br>,与 Obsidian 等编辑器的换行习惯一致CommonMark 默认会把单换行当成空格)
marked.setOptions({ gfm: true, breaks: true });
function decodeHtmlEntities(str) {
return String(str)
.replace(/&nbsp;/g, "\u00a0")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function mermaidFencedToDiv(html) {
return html.replace(
/<pre><code class="language-mermaid">([\s\S]*?)<\/code><\/pre>/gi,
(_, inner) => {
const raw = decodeHtmlEntities(inner);
return `<div class="mermaid">${escapeHtml(raw)}</div>`;
}
);
}
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 = "<p>正文解码失败。</p>";
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 = "<p>渲染失败。</p>";
el.classList.remove("markdown-pending");
});
});
}
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", run);
else run();

131
render.js
View File

@@ -1,131 +0,0 @@
import {
escapeHtml,
escapeAttr,
formatDateTime,
cleanText,
stripMarkdown,
b64EncodeUtf8
} from "./utils.js";
const DEFAULT_SITE_NAME_ZH = "萌芽小窝";
const DEFAULT_SITE_NAME_EN = "SproutBlog";
export function siteNameZh(env) {
return cleanText(env?.SITE_TITLE) || DEFAULT_SITE_NAME_ZH;
}
export function siteNameEn(env) {
return cleanText(env?.SITE_TITLE_EN) || DEFAULT_SITE_NAME_EN;
}
export function pageTitle(env, suffix = "") {
const site = siteNameZh(env);
return suffix ? `${site} - ${suffix}` : site;
}
export function renderLayout(title, body, env, options = {}) {
const siteZh = siteNameZh(env);
const siteEn = siteNameEn(env);
const cwdPostSlug = options.cwdPostSlug ? String(options.cwdPostSlug) : "";
const cwdApiBase =
cleanText(env?.CWD_API_BASE) || "https://cwd.api.smyhub.com";
const cwdSiteId = cleanText(env?.CWD_SITE_ID);
const cwdSection = cwdPostSlug
? `
<section class="post-comments" aria-labelledby="comments-heading">
<h2 id="comments-heading" class="post-comments__title">评论</h2>
<div id="comments" data-api-base="${escapeAttr(cwdApiBase)}" data-post-slug="${escapeAttr(cwdPostSlug)}" data-lang="zh-CN"${cwdSiteId ? ` data-site-id="${escapeAttr(cwdSiteId)}"` : ""}></div>
</section>`
: "";
const mdHead = options.markdownPage
? `
<link rel="stylesheet" href="/css/markdown.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.45/dist/katex.min.css" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/styles/github.min.css" crossorigin="anonymous">
`
: "";
const mdScript = options.markdownPage
? `\n <script type="module" src="/js/markdown-client.js"></script>`
: "";
const cwdScripts = cwdPostSlug
? `
<script src="https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js" defer></script>
<script src="/js/cwd-comments.js" defer></script>`
: "";
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
<title>${escapeHtml(title)}</title>
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="stylesheet" href="/css/app.css">
${mdHead}
</head>
<body>
<main>
<header class="site-header">
<div class="site-brand">
<img class="site-logo" src="/logo.png" alt="" width="40" height="40" decoding="async">
<div class="site-titles">
<a href="/" class="site-title-link">${escapeHtml(siteZh)}</a>
<span class="site-title-en">${escapeHtml(siteEn)}</span>
</div>
</div>
</header>
${body}
${cwdSection}
</main>
<footer class="site-footer">
<p>萌芽小窝-一个安静的小地方 @2026-至今</p>
</footer>
<div id="admin-gate" class="admin-gate" hidden>
<div class="admin-gate-panel">
<input id="admin-token" type="password" autocomplete="off" placeholder="Token" aria-label="Token">
<button type="button" id="admin-token-go">进入</button>
<button type="button" id="admin-token-cancel">取消</button>
</div>
</div>
<script src="/js/app.js" defer></script>${mdScript}${cwdScripts}
</body>
</html>`;
}
export function renderPostCard(post) {
const excerpt = post.excerpt || stripMarkdown(post.content).slice(0, 180);
const views = Number(post.view_count) || 0;
return `
<article class="post-card">
<h2><a href="/post/${encodeURIComponent(post.slug)}">${escapeHtml(post.title)}</a></h2>
<p class="post-excerpt">${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}</p>
<p class="post-meta">
<span class="post-card-time">创建于:${formatDateTime(post.created_at)}</span>
<span class="post-card-time">最后更新于:${formatDateTime(post.updated_at)}</span>
<span class="post-card-views">浏览 ${views}</span>
</p>
</article>
`;
}
export function renderAdminCard(post) {
return `
<article>
<h3>${escapeHtml(post.title)}</h3>
<p>ID: ${post.id} | Slug: ${escapeHtml(post.slug)} | ${post.published ? "已发布" : "草稿"} | ${formatDateTime(post.updated_at)}</p>
<div>
<a href="/admin/edit/${post.id}">编辑</a>
<form method="post" action="/admin/delete" style="display:inline">
<input type="hidden" name="id" value="${post.id}">
<button type="submit">删除</button>
</form>
</div>
</article>
`;
}
export function markdownPlaceholder(raw) {
const src = String(raw ?? "");
if (!src.trim()) return "";
const b64 = b64EncodeUtf8(src);
return `<div class="markdown-body markdown-pending" data-b64-md="${escapeAttr(b64)}"><p class="markdown-loading">加载中…</p></div>`;
}

504
server.js
View File

@@ -1,504 +0,0 @@
import {
renderLayout,
renderPostCard,
renderAdminCard,
markdownPlaceholder,
pageTitle
} from "./render.js";
import {
escapeHtml,
escapeAttr,
formatDateTime,
cleanText,
isTruthy
} from "./utils.js";
export default {
async fetch(request, env) {
const url = new URL(request.url);
const path = normalizePath(url.pathname);
if (!env.DB) {
return htmlResponse(renderLayout("配置缺失", `
<h1>DB 绑定未配置</h1>
<p>请先在 <code>wrangler.toml</code> 里配置 D1 数据库。</p>
`, env), 500);
}
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
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));
if (path === "/admin" || path === "/admin/new" || path.startsWith("/admin/edit/")) {
return await handleAdminPage(request, env, url, path);
}
if (path === "/api/posts") return await handleApiList(env, url);
if (path.startsWith("/api/posts/")) return await handleApiRead(env, path.slice(11));
}
if (path === "/admin/save" && request.method === "POST") {
return await handleAdminSave(request, env, url);
}
if (path === "/admin/delete" && request.method === "POST") {
return await handleAdminDelete(request, env, url);
}
if (path === "/api/posts" && request.method === "POST") {
return await handleApiCreate(request, env);
}
if (path.startsWith("/api/posts/")) {
const idOrSlug = path.slice(11);
if (request.method === "PUT") return await handleApiUpdate(request, env, idOrSlug);
if (request.method === "DELETE") return await handleApiDelete(request, env, idOrSlug);
}
return notFoundPage(env);
} catch (error) {
return htmlResponse(renderLayout("错误", `
<h1>发生错误</h1>
<pre>${escapeHtml(error?.stack || error?.message || String(error))}</pre>
`, env), 500);
}
}
};
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, "首页"), `
${q ? `<p>搜索:<strong>${escapeHtml(q)}</strong></p>` : ""}
${posts.length ? posts.map(renderPostCard).join("") : "<p>暂无文章。</p>"}
`, env));
}
async function handlePost(env, slug) {
const post = await env.DB.prepare(
"SELECT * FROM posts WHERE slug = ? AND published = 1 LIMIT 1"
).bind(decodeURIComponent(slug)).first();
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)}`, `
<p><a href="/">返回</a></p>
<article>
<h1>${escapeHtml(post.title)}</h1>
<p class="post-detail-meta"><span class="post-detail-time">${formatDateTime(post.updated_at)}</span><span class="post-detail-views">浏览 ${views}</span></p>
${markdownPlaceholder(post.content)}
</article>
`, env, { markdownPage: true, cwdPostSlug: post.slug }));
}
async function handleAdminPage(request, env, url, path) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
let post = blankPost();
if (path.startsWith("/admin/edit/")) {
const id = Number(path.slice("/admin/edit/".length));
if (Number.isFinite(id) && id > 0) {
post = await getPostById(env.DB, id) || post;
}
} else if (path === "/admin" && url.searchParams.get("id")) {
const id = Number(url.searchParams.get("id"));
if (Number.isFinite(id) && id > 0) {
post = await getPostById(env.DB, id) || post;
}
}
const q = (url.searchParams.get("q") || "").trim();
const posts = await listPosts(env.DB, { publishedOnly: false, q });
return htmlResponse(renderLayout(`后台 - ${pageTitle(env)}`, `
<p><a href="/">返回</a></p>
<h1>后台</h1>
<section>
<h2>${post.id ? `编辑 #${post.id}` : "新建文章"}</h2>
<form method="post" action="/admin/save">
<input type="hidden" name="id" value="${escapeAttr(post.id || "")}">
<p>
<label>标题<br><input name="title" type="text" value="${escapeAttr(post.title || "")}" required></label>
</p>
<p>
<label>Slug<br><input name="slug" type="text" value="${escapeAttr(post.slug || "")}"></label>
</p>
<p>
<label>摘要<br><input name="excerpt" type="text" value="${escapeAttr(post.excerpt || "")}"></label>
</p>
<p>
<label>内容<br><textarea name="content" required>${escapeHtml(post.content || "")}</textarea></label>
</p>
<p>
<label><input name="published" type="checkbox" value="1" ${post.published ? "checked" : ""}> 发布</label>
</p>
<p><button type="submit">保存</button></p>
</form>
</section>
<hr>
<section>
<h2>文章列表</h2>
<form method="get" action="/admin">
<p>
<label>查询<br><input name="q" type="text" value="${escapeAttr(q)}"></label>
</p>
<p><button type="submit">搜索</button></p>
</form>
${posts.length ? posts.map(renderAdminCard).join("") : "<p>没有找到文章。</p>"}
</section>
`, env));
}
async function handleAdminSave(request, env, url) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
const data = await readBody(request);
const rawId = data.id ? Number(data.id) : null;
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
const title = cleanText(data.title);
const content = cleanText(data.content);
const excerpt = cleanText(data.excerpt);
const slugBase = cleanText(data.slug) || title;
const published = isTruthy(data.published) ? 1 : 0;
if (!title || !content) {
return htmlResponse(renderLayout("表单错误", `
<h1>标题和内容不能为空</h1>
<p><a href="/admin">返回后台</a></p>
`, env), 400);
}
const slug = await uniqueSlug(env.DB, slugify(slugBase), id);
const now = new Date().toISOString();
if (id) {
const exists = await getPostById(env.DB, id);
if (!exists) return notFoundPage(env);
await env.DB.prepare(`
UPDATE posts
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
WHERE id = ?
`).bind(title, slug, excerpt, content, published, now, id).run();
} else {
const result = await env.DB.prepare(`
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(title, slug, excerpt, content, published, now, now).run();
return Response.redirect(new URL(`/admin?id=${result.meta.last_row_id}&saved=1`, url), 303);
}
return Response.redirect(new URL(`/admin?id=${id}&saved=1`, url), 303);
}
async function handleAdminDelete(request, env, url) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
const data = await readBody(request);
const id = Number(data.id);
if (!Number.isFinite(id) || id <= 0) {
return htmlResponse(renderLayout("表单错误", `
<h1>无效的文章 ID</h1>
<p><a href="/admin">返回后台</a></p>
`, 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 });
return jsonResponse({ ok: true, posts });
}
async function handleApiRead(env, idOrSlug) {
const post = await readPost(env.DB, idOrSlug);
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
return jsonResponse({ ok: true, post });
}
async function handleApiCreate(request, env) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
const data = await readBody(request);
const created = await createOrUpdatePost(env.DB, data, null);
return jsonResponse({ ok: true, post: created }, 201);
}
async function handleApiUpdate(request, env, idOrSlug) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
const data = await readBody(request);
const existing = await readPost(env.DB, idOrSlug);
if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404);
const updated = await createOrUpdatePost(env.DB, data, existing.id);
return jsonResponse({ ok: true, post: updated });
}
async function handleApiDelete(request, env, idOrSlug) {
const auth = await requireAdmin(request, env);
if (auth) return auth;
const post = await readPost(env.DB, idOrSlug);
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(post.id).run();
return jsonResponse({ ok: true, deleted: post.id });
}
async function createOrUpdatePost(db, data, currentId) {
const rawId = currentId ? Number(currentId) : null;
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
const title = cleanText(data.title);
const content = cleanText(data.content);
const excerpt = cleanText(data.excerpt);
const slugBase = cleanText(data.slug) || title;
const published = isTruthy(data.published) ? 1 : 0;
if (!title || !content) {
throw new Error("title 和 content 不能为空");
}
const slug = await uniqueSlug(db, slugify(slugBase), id);
const now = new Date().toISOString();
if (id) {
await db.prepare(`
UPDATE posts
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
WHERE id = ?
`).bind(title, slug, excerpt, content, published, now, id).run();
return await getPostById(db, id);
}
const result = await db.prepare(`
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).bind(title, slug, excerpt, content, published, now, now).run();
return await getPostById(db, result.meta.last_row_id);
}
async function readPost(db, idOrSlug) {
if (!idOrSlug) return null;
const trimmed = decodeURIComponent(String(idOrSlug)).trim();
if (/^\d+$/.test(trimmed)) {
return await getPostById(db, Number(trimmed));
}
return await db.prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1").bind(trimmed).first();
}
async function getPostById(db, id) {
return await db.prepare("SELECT * FROM posts WHERE id = ? LIMIT 1").bind(Number(id)).first();
}
async function listPosts(db, { publishedOnly = true, q = "" } = {}) {
const params = [];
const where = [];
if (publishedOnly) where.push("published = 1");
if (q) {
where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)");
const like = `%${q}%`;
params.push(like, like, like, like);
}
const sql = `
SELECT * FROM posts
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY published DESC, updated_at DESC, id DESC
`;
const statement = db.prepare(sql);
const { results } = params.length ? await statement.bind(...params).all() : await statement.all();
return results || [];
}
async function uniqueSlug(db, baseSlug, currentId) {
let slug = baseSlug || "post";
let suffix = 2;
while (true) {
const existing = await db.prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1").bind(slug).first();
if (!existing || Number(existing.id) === Number(currentId)) return slug;
slug = `${baseSlug || "post"}-${suffix++}`;
}
}
function slugify(value) {
const raw = cleanText(value);
const slug = raw
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-")
.toLowerCase();
return slug || "post";
}
function normalizePath(pathname) {
if (pathname === "/") return "/";
return pathname.replace(/\/+$/, "");
}
function htmlResponse(html, status = 200, headers = {}) {
return new Response(html, {
status,
headers: {
"content-type": "text/html; charset=UTF-8",
...headers
}
});
}
function jsonResponse(data, status = 200, extraHeaders = {}) {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: {
"content-type": "application/json; charset=UTF-8",
...corsHeaders(),
...extraHeaders
}
});
}
function notFoundPage(env) {
return htmlResponse(renderLayout("404", `
<h1>404</h1>
<p>页面不存在。</p>
<p><a href="/">返回</a></p>
`, env), 404);
}
function corsHeaders() {
return {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-headers": "*"
};
}
async function requireAdmin(request, env) {
const password = cleanText(env.ADMIN_PASSWORD);
if (!password) return null;
const cookies = parseCookie(request.headers.get("Cookie") || "");
const expected = await adminSessionCookieValue(env);
if (cookies.sb_admin && cookies.sb_admin === expected) {
return null;
}
const header = request.headers.get("Authorization") || "";
if (header.startsWith("Basic ")) {
try {
const decoded = atob(header.slice(6));
const split = decoded.indexOf(":");
const inputPassword = split >= 0 ? decoded.slice(split + 1) : "";
if (inputPassword === password) return null;
} catch {
// ignore
}
}
return unauthorized();
}
function parseCookie(header) {
const out = {};
if (!header) return out;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
try {
out[k] = decodeURIComponent(v);
} catch {
out[k] = v;
}
}
return out;
}
async function adminSessionCookieValue(env) {
const password = cleanText(env.ADMIN_PASSWORD);
const enc = new TextEncoder();
const data = enc.encode(`${password}\0sproutblog_admin_v1`);
const hash = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
function unauthorized() {
return new Response("Authentication required", {
status: 401,
headers: {
"content-type": "text/plain; charset=UTF-8"
}
});
}
async function readBody(request) {
const type = request.headers.get("content-type") || "";
if (type.includes("application/json")) {
return await request.json();
}
if (type.includes("multipart/form-data") || type.includes("application/x-www-form-urlencoded")) {
const form = await request.formData();
const data = {};
for (const [key, value] of form.entries()) {
data[key] = value;
}
return data;
}
return {};
}
function blankPost() {
return {
id: "",
title: "",
slug: "",
excerpt: "",
content: "",
published: 1
};
}

56
src/api/router.ts Normal file
View File

@@ -0,0 +1,56 @@
import { jsonResponse, corsHeaders } from "../lib/http";
import { normalizePath } from "../lib/slug";
import { handleConfigGet } from "./routes/config";
import { handleAdminSessionPost } from "./routes/admin-session";
import {
handlePostsCollection,
handlePostItem
} from "./routes/posts";
export async function handleApi(request: Request, env: Env): Promise<Response> {
if (!env.DB) {
return jsonResponse({ ok: false, error: "db_not_configured" }, 500);
}
if (request.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders() });
}
const url = new URL(request.url);
const path = normalizePath(url.pathname);
const segments = path.split("/").filter(Boolean);
if (segments[0] !== "api") {
return jsonResponse({ ok: false, error: "Not found" }, 404);
}
const parts = segments.slice(1);
if (
parts[0] === "config" &&
parts.length === 1 &&
request.method === "GET"
) {
return handleConfigGet(env);
}
if (
parts[0] === "admin" &&
parts[1] === "session" &&
parts.length === 2 &&
request.method === "POST"
) {
return handleAdminSessionPost(request, env);
}
if (parts[0] === "posts") {
if (parts.length === 1) {
return handlePostsCollection(request, env, url);
}
if (parts.length === 2) {
return handlePostItem(request, env, url, parts[1]);
}
}
return jsonResponse({ ok: false, error: "Not found" }, 404);
}

View File

@@ -0,0 +1,29 @@
import { jsonResponse, readBody, getAdminPassword } from "../../lib/http";
import { cleanText } from "../../lib/text";
import { adminSessionCookieValue } from "../../lib/auth";
export async function handleAdminSessionPost(
request: Request,
env: Env
): Promise<Response> {
const password = getAdminPassword(env);
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("; ")
});
}

17
src/api/routes/config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { jsonResponse } from "../../lib/http";
import { cleanText } from "../../lib/text";
export function handleConfigGet(env: Env): Response {
const cwdApiBase =
cleanText(env.CWD_API_BASE) || "https://cwd.api.smyhub.com";
const cwdSiteId = cleanText(env.CWD_SITE_ID) || "";
return jsonResponse({
ok: true,
config: {
siteTitle: cleanText(env.SITE_TITLE) || "萌芽小窝",
siteTitleEn: cleanText(env.SITE_TITLE_EN) || "SproutBlog",
cwdApiBase,
cwdSiteId: cwdSiteId || undefined
}
});
}

113
src/api/routes/posts.ts Normal file
View File

@@ -0,0 +1,113 @@
import { jsonResponse, readBody } from "../../lib/http";
import { requireAdmin, canAccessDrafts, hasValidAdminSession } from "../../lib/auth";
import {
listPosts,
readPost,
createOrUpdatePost,
bumpViewCount,
deletePost,
type PostRow
} from "../../db/posts";
function numPublished(p: PostRow): number {
return Number(p.published) ? 1 : 0;
}
async function listForRequest(
request: Request,
env: Env,
url: URL
): Promise<Response> {
const q = (url.searchParams.get("q") || "").trim();
const seeAll = await canAccessDrafts(request, env);
const publishedOnly = !seeAll;
const posts = await listPosts(env.DB, { publishedOnly, q });
return jsonResponse({ ok: true, posts });
}
export async function handlePostsCollection(
request: Request,
env: Env,
url: URL
): Promise<Response> {
if (request.method === "GET") {
return listForRequest(request, env, url);
}
if (request.method === "POST") {
const denied = await requireAdmin(request, env);
if (denied) return denied;
const data = await readBody(request);
try {
const created = await createOrUpdatePost(env.DB, data, null);
return jsonResponse({ ok: true, post: created }, 201);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse({ ok: false, error: msg }, 400);
}
}
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}
export async function handlePostItem(
request: Request,
env: Env,
url: URL,
idOrSlug: string
): Promise<Response> {
const bump = url.searchParams.get("bump") === "1";
if (request.method === "GET") {
const post = await readPost(env.DB, idOrSlug);
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
const draftsOk = await canAccessDrafts(request, env);
if (!draftsOk && numPublished(post) !== 1) {
return jsonResponse({ ok: false, error: "Not found" }, 404);
}
let row = post;
const shouldBump =
bump &&
numPublished(post) === 1 &&
!(await hasValidAdminSession(request, env));
if (shouldBump) {
await bumpViewCount(env.DB, Number(post.id));
const refreshed = await readPost(env.DB, idOrSlug);
if (refreshed) row = refreshed;
}
return jsonResponse({ ok: true, post: row });
}
if (request.method === "PUT") {
const denied = await requireAdmin(request, env);
if (denied) return denied;
const data = await readBody(request);
const existing = await readPost(env.DB, idOrSlug);
if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404);
try {
const updated = await createOrUpdatePost(
env.DB,
data,
Number(existing.id)
);
return jsonResponse({ ok: true, post: updated });
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
return jsonResponse({ ok: false, error: msg }, 400);
}
}
if (request.method === "DELETE") {
const denied = await requireAdmin(request, env);
if (denied) return denied;
const post = await readPost(env.DB, idOrSlug);
if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404);
await deletePost(env.DB, Number(post.id));
return jsonResponse({ ok: true, deleted: post.id });
}
return jsonResponse({ ok: false, error: "Method not allowed" }, 405);
}

9
src/bindings.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
interface Env {
DB: D1Database;
ASSETS: Fetcher;
SITE_TITLE: string;
SITE_TITLE_EN: string;
ADMIN_PASSWORD?: string;
CWD_API_BASE?: string;
CWD_SITE_ID?: string;
}

135
src/db/posts.ts Normal file
View File

@@ -0,0 +1,135 @@
import { cleanText, isTruthy } from "../lib/text";
import { slugify } from "../lib/slug";
type Db = Env["DB"];
export type PostRow = Record<string, unknown>;
export function blankPost(): PostRow {
return {
id: "",
title: "",
slug: "",
excerpt: "",
content: "",
published: 1
};
}
export async function getPostById(db: Db, id: number): Promise<PostRow | null> {
return (await db
.prepare("SELECT * FROM posts WHERE id = ? LIMIT 1")
.bind(Number(id))
.first()) as PostRow | null;
}
export async function readPost(db: Db, idOrSlug: string): Promise<PostRow | null> {
if (!idOrSlug) return null;
const trimmed = decodeURIComponent(String(idOrSlug)).trim();
if (/^\d+$/.test(trimmed)) {
return getPostById(db, Number(trimmed));
}
return (await db
.prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1")
.bind(trimmed)
.first()) as PostRow | null;
}
export async function listPosts(
db: Db,
{ publishedOnly = true, q = "" } = {}
): Promise<PostRow[]> {
const params: string[] = [];
const where: string[] = [];
if (publishedOnly) where.push("published = 1");
if (q) {
where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)");
const like = `%${q}%`;
params.push(like, like, like, like);
}
const sql = `
SELECT * FROM posts
${where.length ? `WHERE ${where.join(" AND ")}` : ""}
ORDER BY published DESC, updated_at DESC, id DESC
`;
const statement = db.prepare(sql);
const { results } = params.length
? await statement.bind(...params).all()
: await statement.all();
return (results || []) as PostRow[];
}
export async function uniqueSlug(
db: Db,
baseSlug: string,
currentId: number | null
): Promise<string> {
let slug = baseSlug || "post";
let suffix = 2;
while (true) {
const existing = (await db
.prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1")
.bind(slug)
.first()) as { id: number } | null;
if (!existing || Number(existing.id) === Number(currentId)) return slug;
slug = `${baseSlug || "post"}-${suffix++}`;
}
}
export async function createOrUpdatePost(
db: Db,
data: Record<string, unknown>,
currentId: number | null
): Promise<PostRow | null> {
const rawId = currentId != null ? Number(currentId) : NaN;
const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null;
const title = cleanText(data.title);
const content = cleanText(data.content);
const excerpt = cleanText(data.excerpt);
const slugBase = cleanText(data.slug) || title;
const published = isTruthy(data.published) ? 1 : 0;
if (!title || !content) {
throw new Error("title 和 content 不能为空");
}
const slug = await uniqueSlug(db, slugify(slugBase), id);
const now = new Date().toISOString();
if (id) {
await db
.prepare(`
UPDATE posts
SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ?
WHERE id = ?
`)
.bind(title, slug, excerpt, content, published, now, id)
.run();
return getPostById(db, id);
}
const result = await db
.prepare(`
INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`)
.bind(title, slug, excerpt, content, published, now, now)
.run();
const newId = result.meta?.last_row_id;
if (newId == null) return null;
return getPostById(db, Number(newId));
}
export async function bumpViewCount(db: Db, postId: number): Promise<void> {
await db
.prepare(
"UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?"
)
.bind(postId)
.run();
}
export async function deletePost(db: Db, id: number): Promise<void> {
await db.prepare("DELETE FROM posts WHERE id = ?").bind(id).run();
}

78
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,78 @@
import { cleanText } from "./text";
import { getAdminPassword, unauthorized } from "./http";
export function parseCookie(header: string): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
try {
out[k] = decodeURIComponent(v);
} catch {
out[k] = v;
}
}
return out;
}
export async function adminSessionCookieValue(env: Env): Promise<string> {
const password = getAdminPassword(env);
const enc = new TextEncoder();
const data = enc.encode(`${password}\0sproutblog_admin_v1`);
const hash = await crypto.subtle.digest("SHA-256", data);
return [...new Uint8Array(hash)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* 需要后台权限的接口:未配置密码则放行;否则校验 Cookie 或 Basic Auth。
* 返回 null 表示已通过;返回 Response 为 401。
*/
export async function requireAdmin(
request: Request,
env: Env
): Promise<Response | null> {
const password = getAdminPassword(env);
if (!password) return null;
const cookies = parseCookie(request.headers.get("Cookie") || "");
const expected = await adminSessionCookieValue(env);
if (cookies.sb_admin && cookies.sb_admin === expected) {
return null;
}
const header = request.headers.get("Authorization") || "";
if (header.startsWith("Basic ")) {
try {
const decoded = atob(header.slice(6));
const split = decoded.indexOf(":");
const inputPassword = split >= 0 ? decoded.slice(split + 1) : "";
if (inputPassword === password) return null;
} catch {
/* ignore */
}
}
return unauthorized();
}
/** 已配置密码且当前请求具备后台凭证Cookie / Basic */
export async function hasValidAdminSession(
request: Request,
env: Env
): Promise<boolean> {
const password = getAdminPassword(env);
if (!password) return false;
return (await requireAdmin(request, env)) === null;
}
/** 是否可列出/阅读草稿(未配置密码时保持原先「全量可见」行为) */
export async function canAccessDrafts(request: Request, env: Env): Promise<boolean> {
const password = getAdminPassword(env);
if (!password) return true;
return (await requireAdmin(request, env)) === null;
}

63
src/lib/http.ts Normal file
View File

@@ -0,0 +1,63 @@
import { cleanText } from "./text";
export function corsHeaders(): Record<string, string> {
return {
"access-control-allow-origin": "*",
"access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS",
"access-control-allow-headers": "*"
};
}
export function jsonResponse(
data: unknown,
status = 200,
extraHeaders: Record<string, string> = {}
): Response {
return new Response(JSON.stringify(data, null, 2), {
status,
headers: {
"content-type": "application/json; charset=UTF-8",
...corsHeaders(),
...extraHeaders
}
});
}
export async function readBody(request: Request): Promise<Record<string, unknown>> {
const type = request.headers.get("content-type") || "";
if (type.includes("application/json")) {
try {
return (await request.json()) as Record<string, unknown>;
} catch {
return {};
}
}
if (
type.includes("multipart/form-data") ||
type.includes("application/x-www-form-urlencoded")
) {
const form = await request.formData();
const data: Record<string, unknown> = {};
for (const [key, value] of form.entries()) {
data[key] = value;
}
return data;
}
return {};
}
export function unauthorized(): Response {
return new Response("Authentication required", {
status: 401,
headers: {
"content-type": "text/plain; charset=UTF-8"
}
});
}
/** Basic Auth / Cookie 中的密码校验用原始字符串 */
export function getAdminPassword(env: Pick<Env, "ADMIN_PASSWORD">): string {
return cleanText(env.ADMIN_PASSWORD);
}

18
src/lib/slug.ts Normal file
View File

@@ -0,0 +1,18 @@
import { cleanText } from "./text";
export function normalizePath(pathname: string): string {
if (pathname === "/") return "/";
return pathname.replace(/\/+$/, "");
}
export function slugify(value: unknown): string {
const raw = cleanText(value);
const slug = raw
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "")
.replace(/-+/g, "-")
.toLowerCase();
return slug || "post";
}

Some files were not shown because too many files have changed in this diff Show More