This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -0,0 +1,199 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
|
||||
/**
|
||||
* 统一的 API 响应格式与错误码。
|
||||
*
|
||||
* 所有接口遵循同一契约,便于前端与脚本一致地处理:
|
||||
*
|
||||
* 成功:{ "data": ..., "meta"?: ... }
|
||||
* 失败:{ "error": { "code": "...", "message": "...", "details"?: [...] } }
|
||||
*
|
||||
* skill 要求"显式优于隐式":错误码是机器可读的稳定标识,
|
||||
* message 面向人,details 用于字段级校验错误。
|
||||
*/
|
||||
|
||||
/** 机器可读的错误码 */
|
||||
export const ErrorCode = {
|
||||
VALIDATION_ERROR: "VALIDATION_ERROR",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
CONFLICT: "CONFLICT",
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
INTERNAL_ERROR: "INTERNAL_ERROR",
|
||||
METHOD_NOT_ALLOWED: "METHOD_NOT_ALLOWED",
|
||||
SERVICE_UNAVAILABLE: "SERVICE_UNAVAILABLE",
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
/** 错误码 → HTTP 状态码 */
|
||||
const STATUS_BY_CODE: Record<ErrorCodeValue, number> = {
|
||||
VALIDATION_ERROR: 400,
|
||||
BAD_REQUEST: 400,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
INTERNAL_ERROR: 500,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
};
|
||||
|
||||
export interface ApiErrorDetail {
|
||||
/** 出错的字段路径,如 "slug" 或 "tags.0" */
|
||||
field?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 业务异常:路由中抛出,由 withErrorHandling 统一转成响应 */
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
readonly code: ErrorCodeValue,
|
||||
message: string,
|
||||
readonly details?: ApiErrorDetail[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
|
||||
static notFound(message = "资源不存在") {
|
||||
return new ApiError(ErrorCode.NOT_FOUND, message);
|
||||
}
|
||||
|
||||
static conflict(message: string, details?: ApiErrorDetail[]) {
|
||||
return new ApiError(ErrorCode.CONFLICT, message, details);
|
||||
}
|
||||
|
||||
static badRequest(message: string, details?: ApiErrorDetail[]) {
|
||||
return new ApiError(ErrorCode.BAD_REQUEST, message, details);
|
||||
}
|
||||
|
||||
static validation(message: string, details?: ApiErrorDetail[]) {
|
||||
return new ApiError(ErrorCode.VALIDATION_ERROR, message, details);
|
||||
}
|
||||
}
|
||||
|
||||
/** 成功响应 */
|
||||
export function ok<T>(data: T, meta?: Record<string, unknown>, status = 200) {
|
||||
return NextResponse.json(meta ? { data, meta } : { data }, { status });
|
||||
}
|
||||
|
||||
/** 创建成功(201) */
|
||||
export function created<T>(data: T) {
|
||||
return NextResponse.json({ data }, { status: 201 });
|
||||
}
|
||||
|
||||
/** 无内容(删除成功) */
|
||||
export function noContent() {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
/** 失败响应 */
|
||||
export function fail(
|
||||
code: ErrorCodeValue,
|
||||
message: string,
|
||||
details?: ApiErrorDetail[],
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: { code, message, ...(details ? { details } : {}) } },
|
||||
{ status: STATUS_BY_CODE[code] },
|
||||
);
|
||||
}
|
||||
|
||||
/** 把 Zod 的校验错误转成字段级 details */
|
||||
function zodToDetails(error: ZodError): ApiErrorDetail[] {
|
||||
return error.issues.map((issue) => ({
|
||||
field: issue.path.join(".") || undefined,
|
||||
message: issue.message,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误对象中提取 Postgres 错误码。
|
||||
*
|
||||
* 关键点:Drizzle 会把驱动抛出的原始错误包在自己的 `DrizzleQueryError`
|
||||
* 里,因此 `error.code` 是 undefined,真正的 PG 错误码在 `error.cause.code`。
|
||||
* 这里沿 cause 链向下查找,最多 5 层,避免无限循环。
|
||||
*/
|
||||
function extractPostgresErrorCode(error: unknown): string | undefined {
|
||||
let current = error;
|
||||
|
||||
for (let depth = 0; depth < 5 && current; depth++) {
|
||||
const code = (current as { code?: unknown }).code;
|
||||
|
||||
// PG 错误码是 5 位数字字符串(如 23505);排除其他库的同名字段
|
||||
if (typeof code === "string" && /^\d{5}$/.test(code)) {
|
||||
return code;
|
||||
}
|
||||
|
||||
current = (current as { cause?: unknown }).cause;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 路由处理器的错误包装器。
|
||||
*
|
||||
* 统一处理三类异常:
|
||||
* 1. ApiError → 按错误码返回
|
||||
* 2. ZodError → 400 + 字段级 details
|
||||
* 3. 其他(含数据库错误)→ 500,并把细节写进服务端日志
|
||||
*
|
||||
* 数据库约束冲突单独识别为 4xx,这对 slug 重复是常见场景。
|
||||
*/
|
||||
export function withErrorHandling<Args extends unknown[]>(
|
||||
handler: (...args: Args) => Promise<NextResponse>,
|
||||
) {
|
||||
return async (...args: Args): Promise<NextResponse> => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
return fail(error.code, error.message, error.details);
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
return fail(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"请求参数校验失败",
|
||||
zodToDetails(error),
|
||||
);
|
||||
}
|
||||
|
||||
const pgCode = extractPostgresErrorCode(error);
|
||||
|
||||
// 唯一约束冲突
|
||||
if (pgCode === "23505") {
|
||||
return fail(ErrorCode.CONFLICT, "该 slug 已存在,请换一个", [
|
||||
{ field: "slug", message: "该 slug 已被占用" },
|
||||
]);
|
||||
}
|
||||
// CHECK 约束冲突(如非法 status/slug 格式)
|
||||
if (pgCode === "23514") {
|
||||
return fail(
|
||||
ErrorCode.VALIDATION_ERROR,
|
||||
"数据不满足数据库约束,请检查 status 与 slug 格式",
|
||||
);
|
||||
}
|
||||
// 非空约束冲突
|
||||
if (pgCode === "23502") {
|
||||
return fail(ErrorCode.VALIDATION_ERROR, "缺少必填字段");
|
||||
}
|
||||
// 字符串长度超限
|
||||
if (pgCode === "22001") {
|
||||
return fail(ErrorCode.VALIDATION_ERROR, "字段长度超出限制");
|
||||
}
|
||||
|
||||
console.error("[api] 未处理的异常:", error);
|
||||
return fail(ErrorCode.INTERNAL_ERROR, "服务器内部错误");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析并校验 JSON 请求体 */
|
||||
export async function parseJsonBody(request: Request): Promise<unknown> {
|
||||
try {
|
||||
return await request.json();
|
||||
} catch {
|
||||
throw ApiError.badRequest("请求体不是合法的 JSON");
|
||||
}
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
import "server-only";
|
||||
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import * as schema from "./schema";
|
||||
|
||||
/**
|
||||
* 数据库客户端(node-postgres 连接池)。
|
||||
*
|
||||
* ## 为什么用 pg 而不是 neon-http
|
||||
*
|
||||
* 脚手架原本使用 `drizzle-orm/neon-http` + `@neondatabase/serverless`。
|
||||
* 该驱动走的是 **Neon 的 HTTP 代理端点**,不是 Postgres 的 TCP 协议,
|
||||
* 因此无法连接本地或自建的普通 Postgres。
|
||||
*
|
||||
* 本项目改用 `pg`(node-postgres),它既能连本地 Postgres,
|
||||
* 也能连 Neon / Supabase / Railway 等任何标准 Postgres——
|
||||
* 部署到 Neon 时只需保留其 TCP 连接串(`*.neon.tech` 的 5432 端口)。
|
||||
*
|
||||
* ## 连接池与 Next.js 开发模式
|
||||
*
|
||||
* Next.js 开发模式下模块会被热重载,若不缓存连接池,每次改动都会新建一批
|
||||
* 连接,很快耗尽数据库的 max_connections。因此把 pool 挂到 globalThis 上做单例。
|
||||
*/
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error(
|
||||
"缺少环境变量 DATABASE_URL。请在 .env.local 中配置 Postgres 连接串," +
|
||||
"例如:postgresql://user:password@localhost:5432/blog",
|
||||
);
|
||||
}
|
||||
|
||||
// 收窄为 string,供下方闭包使用(模块级已判空,但闭包内 TS 无法自动收窄)
|
||||
const resolvedConnectionString: string = connectionString;
|
||||
|
||||
/** 在 globalThis 上缓存连接池,避免开发模式热重载导致的连接泄漏 */
|
||||
const globalForDb = globalThis as unknown as {
|
||||
__postgresPool?: Pool;
|
||||
};
|
||||
|
||||
function createPool(): Pool {
|
||||
const pool = new Pool({
|
||||
connectionString: resolvedConnectionString,
|
||||
// 本地开发(localhost)通常没配证书,强制 SSL 会直接失败;
|
||||
// 托管数据库(Neon / Supabase 等)则要求 SSL。
|
||||
ssl: shouldUseSsl(resolvedConnectionString)
|
||||
? { rejectUnauthorized: false }
|
||||
: undefined,
|
||||
// 单实例默认 10 连接足够;Serverless 环境建议调小或接 Neon 的 pooler
|
||||
max: Number(process.env.DATABASE_POOL_MAX ?? 10),
|
||||
idleTimeoutMillis: 30_000,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
// 语句级超时,避免慢查询长期占用连接
|
||||
statement_timeout: Number(process.env.DATABASE_STATEMENT_TIMEOUT_MS ?? 15_000),
|
||||
});
|
||||
|
||||
// 连接池错误不应导致进程崩溃(例如数据库重启期间的瞬时失败)
|
||||
pool.on("error", (error) => {
|
||||
console.error("[db] 空闲连接异常:", error.message);
|
||||
});
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/** 判断是否需要启用 SSL:本地地址不启用,远端默认启用 */
|
||||
function shouldUseSsl(url: string): boolean {
|
||||
try {
|
||||
const { hostname, searchParams } = new URL(url);
|
||||
const sslmode = searchParams.get("sslmode");
|
||||
|
||||
// 显式声明优先
|
||||
if (sslmode === "disable") {
|
||||
return false;
|
||||
}
|
||||
if (sslmode === "require" || sslmode === "verify-full") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 未显式声明时,按主机推断
|
||||
const isLocal =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname.endsWith(".local");
|
||||
|
||||
return !isLocal;
|
||||
} catch {
|
||||
// URL 解析失败时交给 pg 自己报错,这里默认不启用 SSL
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const pool = globalForDb.__postgresPool ?? createPool();
|
||||
|
||||
// 生产环境不需要挂载(模块不会热重载),但挂载也无副作用
|
||||
globalForDb.__postgresPool = pool;
|
||||
|
||||
export const db = drizzle(pool, { schema });
|
||||
|
||||
/** 底层连接池,供健康检查与脚本使用 */
|
||||
export { pool };
|
||||
|
||||
export type DB = typeof db;
|
||||
|
||||
/** 检查数据库连通性,用于健康检查接口 */
|
||||
export async function checkDatabaseHealth(): Promise<{
|
||||
ok: boolean;
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
}> {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
await pool.query("select 1");
|
||||
return { ok: true, latencyMs: Date.now() - startedAt };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
integer,
|
||||
index,
|
||||
uniqueIndex,
|
||||
serial,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* 数据库模型。
|
||||
*
|
||||
* 设计要点:
|
||||
* - `slug` 唯一且带索引:它是文章的唯一对外标识(URL 的一部分)。
|
||||
* - `status` 用枚举字符串而非布尔:草稿/已发布之外,未来可能加归档。
|
||||
* 这里用 varchar + CHECK 约束保证取值合法(Drizzle 的 pgEnum 也可,
|
||||
* 但 enum 类型后续增删值需要 ALTER TYPE,字符串 + CHECK 更灵活)。
|
||||
* - `tags` 使用 text[] 而非独立关联表:v0.1 的标签只需按包含查询,
|
||||
* 数组 + GIN 索引即可满足;引入 tags 关联表会让读写都多一次 join。
|
||||
* - 保留 `created_at` / `updated_at`,便于排序与增量同步。
|
||||
* - `published_at` 单独存:草稿转发布时,发布时间不等于创建时间。
|
||||
*/
|
||||
|
||||
export const posts = pgTable(
|
||||
"posts",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
|
||||
/** URL 标识,全局唯一,仅允许小写字母/数字/连字符 */
|
||||
slug: varchar("slug", { length: 200 }).notNull(),
|
||||
|
||||
/** 标题 */
|
||||
title: text("title").notNull(),
|
||||
|
||||
/** 摘要,用于卡片与 SEO description */
|
||||
summary: text("summary").notNull(),
|
||||
|
||||
/** 正文(Markdown / MDX 源码) */
|
||||
content: text("content").notNull(),
|
||||
|
||||
/** 标签数组 */
|
||||
tags: text("tags")
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`ARRAY[]::text[]`),
|
||||
|
||||
/** 状态:draft(草稿)| published(已发布) */
|
||||
status: varchar("status", { length: 20 }).notNull().default("draft"),
|
||||
|
||||
/** 阅读时长(分钟),写入时计算并缓存,避免列表页重复解析正文 */
|
||||
readingMinutes: integer("reading_minutes").notNull().default(1),
|
||||
|
||||
/** 封面图路径 */
|
||||
cover: text("cover"),
|
||||
|
||||
/** 覆盖默认 SEO 描述 */
|
||||
description: text("description"),
|
||||
|
||||
/** 发布时间;草稿为 null */
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
},
|
||||
(table) => [
|
||||
// slug 是 URL 标识,必须唯一;唯一索引同时承担查询加速
|
||||
uniqueIndex("posts_slug_unique_idx").on(table.slug),
|
||||
|
||||
// 列表页主查询:按状态过滤 + 按发布时间倒序
|
||||
index("posts_status_published_at_idx").on(
|
||||
table.status,
|
||||
table.publishedAt.desc(),
|
||||
),
|
||||
|
||||
// 标签筛选:GIN 索引支持 `tags @> ARRAY['x']` 与 `'x' = ANY(tags)`
|
||||
index("posts_tags_gin_idx").using("gin", table.tags),
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* 数据完整性约束(在迁移 SQL 中手工维护)。
|
||||
*
|
||||
* Drizzle 的 `pgTable` 第三个回调只支持索引类定义,`sql` 模板不会被生成为
|
||||
* CHECK 约束。因此以下约束直接写在迁移文件里,并在应用层用 Zod 做同样的校验:
|
||||
*
|
||||
* - `posts_status_check` : status IN ('draft', 'published')
|
||||
* - `posts_slug_format_check` : slug 匹配 ^[a-z0-9]+(-[a-z0-9]+)*$
|
||||
*
|
||||
* 注意:约束同时存在于数据库与应用层是有意为之——应用层给出友好报错,
|
||||
* 数据库层保证任何写入路径(脚本、手工 SQL)都无法绕过。
|
||||
*/
|
||||
|
||||
export const postsRelations = relations(posts, () => ({}));
|
||||
|
||||
/** 由 schema 推导的插入类型 */
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
|
||||
/** 由 schema 推导的查询类型 */
|
||||
export type PostRow = typeof posts.$inferSelect;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Inter, Noto_Sans_SC, IBM_Plex_Mono } from "next/font/google";
|
||||
|
||||
/**
|
||||
* 站点字体系统(next/font 自托管)。
|
||||
*
|
||||
* 设计规范(DESIGN.md)要求:
|
||||
* - Inter → 英文 / UI 正文
|
||||
* - Noto Sans SC → 中文
|
||||
* - IBM Plex Mono → 代码(规范原文为 Geist Mono,本项目按需求改用 IBM Plex Mono)
|
||||
*
|
||||
* 说明:next/font/google 在 `next build` 阶段下载字体文件并自托管到
|
||||
* `.next/static/media`,运行时不会向 Google 发起请求,也不产生 FOUT/CLS。
|
||||
* 每个字体都显式配置 `fallback`(含 `adjustFontFallback`),保证在字体
|
||||
* 尚未加载或加载失败时,回退栈与目标字体的度量尽量接近。
|
||||
*/
|
||||
|
||||
/** 英文 / UI 主字体:Inter */
|
||||
export const fontInter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
style: ["normal"],
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: [
|
||||
"-apple-system",
|
||||
"BlinkMacSystemFont",
|
||||
"Segoe UI",
|
||||
"Helvetica Neue",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
adjustFontFallback: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* 中文字体:Noto Sans SC
|
||||
*
|
||||
* 该字族在 Google Fonts 上是按权重分片的(非可变字体),因此显式声明需要
|
||||
* 的 4 个权重,由 next/font 生成 unicode-range 分片,浏览器只下载用到的子集。
|
||||
*/
|
||||
export const fontNotoSansSC = Noto_Sans_SC({
|
||||
variable: "--font-noto-sans-sc",
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
style: ["normal"],
|
||||
display: "swap",
|
||||
// 中文字体体积大、子集多,禁用预载以免抢占首屏关键资源。
|
||||
preload: false,
|
||||
fallback: [
|
||||
"PingFang SC",
|
||||
"Hiragino Sans GB",
|
||||
"Microsoft YaHei",
|
||||
"Source Han Sans SC",
|
||||
"WenQuanYi Micro Hei",
|
||||
"sans-serif",
|
||||
],
|
||||
adjustFontFallback: false,
|
||||
});
|
||||
|
||||
/** 代码字体:IBM Plex Mono */
|
||||
export const fontIbmPlexMono = IBM_Plex_Mono({
|
||||
variable: "--font-ibm-plex-mono",
|
||||
subsets: ["latin", "latin-ext"],
|
||||
weight: ["400", "500", "600"],
|
||||
style: ["normal"],
|
||||
display: "swap",
|
||||
preload: true,
|
||||
fallback: [
|
||||
"SFMono-Regular",
|
||||
"SF Mono",
|
||||
"Menlo",
|
||||
"Consolas",
|
||||
"Liberation Mono",
|
||||
"Courier New",
|
||||
"monospace",
|
||||
],
|
||||
adjustFontFallback: true,
|
||||
});
|
||||
|
||||
/** 挂到 <html> 上的字体变量类名。 */
|
||||
export const fontVariables = [
|
||||
fontInter.variable,
|
||||
fontNotoSansSC.variable,
|
||||
fontIbmPlexMono.variable,
|
||||
].join(" ");
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
import "server-only";
|
||||
|
||||
import { compileMDX } from "next-mdx-remote/rsc";
|
||||
import rehypePrettyCode, {
|
||||
type Options as PrettyCodeOptions,
|
||||
} from "rehype-pretty-code";
|
||||
import rehypeSlug from "rehype-slug";
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { mdxComponents } from "@/components/mdx/mdx-components";
|
||||
|
||||
/**
|
||||
* MDX 编译管线。
|
||||
*
|
||||
* 插件顺序(rehype 在 remark 之后执行):
|
||||
* 1. remark-gfm → 表格、任务列表、删除线
|
||||
* 2. rehype-slug → 为标题生成 id(TOC 锚点依赖它)
|
||||
* 3. rehype-autolink-headings → 标题旁生成可点击的 # 锚点
|
||||
* 4. rehype-pretty-code → 基于 Shiki 的语法高亮 + 行号/高亮行
|
||||
*
|
||||
* Shiki 在构建期完成高亮,输出静态 HTML,运行时零 JS 开销。
|
||||
*/
|
||||
|
||||
/** 代码高亮主题:深色 surface-code (#1c1c1e) 上对比度良好 */
|
||||
const CODE_THEME = "github-dark-default";
|
||||
|
||||
const prettyCodeOptions: PrettyCodeOptions = {
|
||||
theme: CODE_THEME,
|
||||
// 保留默认的 pre/code 结构,样式由 globals.css 的 .prose-blog 控制
|
||||
keepBackground: true,
|
||||
defaultLang: "plaintext",
|
||||
};
|
||||
|
||||
export interface CompiledMdx {
|
||||
content: React.ReactElement;
|
||||
}
|
||||
|
||||
/** 编译 MDX 正文为可渲染的 React 元素树 */
|
||||
export async function compilePostMdx(source: string): Promise<CompiledMdx> {
|
||||
const { content } = await compileMDX({
|
||||
source,
|
||||
components: mdxComponents,
|
||||
options: {
|
||||
parseFrontmatter: false,
|
||||
mdxOptions: {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "append",
|
||||
properties: {
|
||||
className: "heading-anchor",
|
||||
ariaLabel: "锚点链接",
|
||||
tabIndex: -1,
|
||||
},
|
||||
content: {
|
||||
type: "text",
|
||||
value: "#",
|
||||
},
|
||||
},
|
||||
],
|
||||
[rehypePrettyCode, prettyCodeOptions],
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return { content };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
listAllPublishedPosts,
|
||||
getPostBySlug as getPostBySlugFromDb,
|
||||
getAdjacentPosts as getAdjacentPostsFromDb,
|
||||
listTags,
|
||||
getPostStats,
|
||||
} from "./repository";
|
||||
import type { Post, PostMeta } from "./types";
|
||||
|
||||
/**
|
||||
* 内容读取门面(服务端)。
|
||||
*
|
||||
* 页面层只依赖本模块,不直接接触仓储实现。这样做的价值:
|
||||
* 数据来源从「文件系统」切换到「PostgreSQL」时,**页面代码一行都不用改**。
|
||||
*
|
||||
* 所有函数默认只返回已发布文章(草稿在仓储层被过滤),
|
||||
* 因此草稿既不出现在列表里,也无法通过直接访问 URL 读到。
|
||||
*/
|
||||
|
||||
export type { Post, PostMeta } from "./types";
|
||||
|
||||
/** 全部已发布文章,按发布日期倒序 */
|
||||
export async function getAllPosts(): Promise<PostMeta[]> {
|
||||
return listAllPublishedPosts();
|
||||
}
|
||||
|
||||
/** 按 slug 读取单篇文章(含正文);草稿与不存在的 slug 均返回 null */
|
||||
export async function getPostBySlug(slug: string): Promise<Post | null> {
|
||||
const post = await getPostBySlugFromDb(slug);
|
||||
return post as Post | null;
|
||||
}
|
||||
|
||||
/** 相邻文章(详情页上下篇导航) */
|
||||
export async function getAdjacentPosts(slug: string) {
|
||||
return getAdjacentPostsFromDb(slug);
|
||||
}
|
||||
|
||||
/** 全部标签及其文章数 */
|
||||
export async function getAllTags(): Promise<{ tag: string; count: number }[]> {
|
||||
return listTags();
|
||||
}
|
||||
|
||||
/** 站点统计(已发布数 / 草稿数 / 标签数) */
|
||||
export async function getStats() {
|
||||
return getPostStats();
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
import "server-only";
|
||||
|
||||
import { and, desc, eq, sql, count } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { posts as postsTable, type PostRow } from "@/lib/db/schema";
|
||||
import { estimateReadingMinutes } from "@/lib/validation/post";
|
||||
import { ApiError } from "@/lib/api/response";
|
||||
import type { PostFrontmatter, PostMeta } from "@/lib/posts/types";
|
||||
|
||||
/**
|
||||
* 文章仓储层。
|
||||
*
|
||||
* 职责边界:
|
||||
* - 只做数据访问与行↔领域对象映射,**不含** HTTP 概念(状态码、请求解析)
|
||||
* - 草稿过滤规则集中在这里:默认查询永远排除草稿
|
||||
*
|
||||
* 页面(Server Component)与 API 路由共用本模块,保证两处行为一致。
|
||||
*/
|
||||
|
||||
/** 数据库行 → 领域对象 */
|
||||
function toPostMeta(row: PostRow): PostMeta {
|
||||
return {
|
||||
slug: row.slug,
|
||||
title: row.title,
|
||||
// 对外仍用 YYYY-MM-DD,保持与原有 frontmatter 契约一致
|
||||
date: (row.publishedAt ?? row.createdAt).toISOString().slice(0, 10),
|
||||
summary: row.summary,
|
||||
tags: row.tags ?? [],
|
||||
draft: row.status === "draft",
|
||||
cover: row.cover ?? undefined,
|
||||
description: row.description ?? undefined,
|
||||
readingMinutes: row.readingMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ListPostsOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
tag?: string;
|
||||
/** 是否包含草稿;默认 false */
|
||||
includeDrafts?: boolean;
|
||||
/** 只看草稿 */
|
||||
draftsOnly?: boolean;
|
||||
/** 显式指定状态;优先级高于 includeDrafts / draftsOnly */
|
||||
status?: "draft" | "published";
|
||||
}
|
||||
|
||||
export interface ListPostsResult {
|
||||
items: PostMeta[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
/** 构造状态过滤条件 */
|
||||
function statusCondition(options: ListPostsOptions) {
|
||||
// 显式 status 优先级最高
|
||||
if (options.status) {
|
||||
return eq(postsTable.status, options.status);
|
||||
}
|
||||
if (options.draftsOnly) {
|
||||
return eq(postsTable.status, "draft");
|
||||
}
|
||||
if (options.includeDrafts) {
|
||||
return undefined;
|
||||
}
|
||||
return eq(postsTable.status, "published");
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询文章列表(不含正文),按发布时间倒序。
|
||||
*
|
||||
* 分页用 limit/offset:v0.1 数据量小,offset 分页足够且实现简单。
|
||||
* 数据量增大后应改游标分页(基于 published_at + id),接口形状可保持不变。
|
||||
*/
|
||||
export async function listPosts(
|
||||
options: ListPostsOptions = {},
|
||||
): Promise<ListPostsResult> {
|
||||
const limit = Math.min(Math.max(options.limit ?? 20, 1), 100);
|
||||
const offset = Math.max(options.offset ?? 0, 0);
|
||||
|
||||
const conditions = [statusCondition(options)];
|
||||
|
||||
if (options.tag) {
|
||||
// 使用 GIN 索引可加速的数组包含判断
|
||||
conditions.push(sql`${postsTable.tags} @> ARRAY[${options.tag}]::text[]`);
|
||||
}
|
||||
|
||||
const where = and(...conditions.filter(Boolean));
|
||||
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(postsTable)
|
||||
.where(where)
|
||||
// NULLS LAST 保证草稿(published_at 为空)排在已发布之后
|
||||
.orderBy(
|
||||
sql`${postsTable.publishedAt} DESC NULLS LAST`,
|
||||
desc(postsTable.createdAt),
|
||||
)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ value: count() }).from(postsTable).where(where),
|
||||
]);
|
||||
|
||||
const total = totalResult[0]?.value ?? 0;
|
||||
|
||||
return {
|
||||
items: rows.map(toPostMeta),
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + rows.length < total,
|
||||
};
|
||||
}
|
||||
|
||||
/** 列出全部已发布文章(用于首页与 sitemap),不分页 */
|
||||
export async function listAllPublishedPosts(): Promise<PostMeta[]> {
|
||||
const result = await listPosts({ limit: 100 });
|
||||
return result.items;
|
||||
}
|
||||
|
||||
/** 数据库行 → 完整文章(含正文) */
|
||||
function toPost(row: PostRow): PostFrontmatter & {
|
||||
slug: string;
|
||||
content: string;
|
||||
readingMinutes: number;
|
||||
} {
|
||||
const meta = toPostMeta(row);
|
||||
return {
|
||||
slug: row.slug,
|
||||
title: meta.title,
|
||||
date: meta.date,
|
||||
summary: meta.summary,
|
||||
tags: meta.tags,
|
||||
draft: meta.draft,
|
||||
cover: meta.cover,
|
||||
description: meta.description,
|
||||
content: row.content,
|
||||
readingMinutes: row.readingMinutes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 slug 读取单篇文章(含正文)。
|
||||
*
|
||||
* 草稿视为不存在(返回 null),因此既不出现在列表里,
|
||||
* 也无法通过直接访问 URL 读到。
|
||||
*/
|
||||
export async function getPostBySlug(slug: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(postsTable)
|
||||
.where(and(eq(postsTable.slug, slug), eq(postsTable.status, "published")))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0 ? toPost(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** 管理用途:按 slug 读取,包含草稿 */
|
||||
export async function getPostBySlugIncludingDrafts(slug: string) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
return rows.length > 0 ? toPost(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** 相邻文章(用于详情页上下篇导航) */
|
||||
export async function getAdjacentPosts(slug: string): Promise<{
|
||||
previous: PostMeta | null;
|
||||
next: PostMeta | null;
|
||||
}> {
|
||||
const all = await listAllPublishedPosts();
|
||||
const index = all.findIndex((post) => post.slug === slug);
|
||||
|
||||
if (index === -1) {
|
||||
return { previous: null, next: null };
|
||||
}
|
||||
|
||||
return {
|
||||
// 列表按发布时间倒序:索引更小 = 更新
|
||||
previous: index > 0 ? all[index - 1] : null,
|
||||
next: index < all.length - 1 ? all[index + 1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 标签聚合:返回标签及其文章数,按出现次数倒序 */
|
||||
export async function listTags(): Promise<{ tag: string; count: number }[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
tag: sql<string>`unnest(${postsTable.tags})`,
|
||||
})
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.status, "published"));
|
||||
|
||||
const counter = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
counter.set(row.tag, (counter.get(row.tag) ?? 0) + 1);
|
||||
}
|
||||
|
||||
return [...counter.entries()]
|
||||
.map(([tag, value]) => ({ tag, count: value }))
|
||||
.sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
|
||||
}
|
||||
|
||||
export interface CreatePostData {
|
||||
slug: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
status: "draft" | "published";
|
||||
publishedAt?: Date;
|
||||
cover?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/** 新建文章 */
|
||||
export async function createPost(data: CreatePostData) {
|
||||
const now = new Date();
|
||||
|
||||
// 发布状态必须有发布时间:未显式提供则用当前时间
|
||||
const publishedAt =
|
||||
data.status === "published" ? (data.publishedAt ?? now) : null;
|
||||
|
||||
const rows = await db
|
||||
.insert(postsTable)
|
||||
.values({
|
||||
slug: data.slug,
|
||||
title: data.title,
|
||||
summary: data.summary,
|
||||
content: data.content,
|
||||
tags: data.tags,
|
||||
status: data.status,
|
||||
publishedAt,
|
||||
cover: data.cover ?? null,
|
||||
description: data.description ?? null,
|
||||
readingMinutes: estimateReadingMinutes(data.content),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toPost(rows[0]);
|
||||
}
|
||||
|
||||
export interface UpdatePostData {
|
||||
slug?: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
content?: string;
|
||||
tags?: string[];
|
||||
status?: "draft" | "published";
|
||||
publishedAt?: Date;
|
||||
cover?: string | null;
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新文章。
|
||||
*
|
||||
* 状态流转规则(有意显式处理,避免隐式副作用):
|
||||
* - draft → published:若没有 publishedAt,则补上当前时间
|
||||
* - published → draft:清空 publishedAt(草稿没有发布时间)
|
||||
*/
|
||||
export async function updatePost(
|
||||
slug: string,
|
||||
data: UpdatePostData,
|
||||
): Promise<ReturnType<typeof toPost> | null> {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const current = existing[0];
|
||||
const nextStatus = data.status ?? current.status;
|
||||
|
||||
let publishedAt = current.publishedAt;
|
||||
if (data.publishedAt !== undefined) {
|
||||
publishedAt = data.publishedAt;
|
||||
} else if (nextStatus === "published" && !current.publishedAt) {
|
||||
publishedAt = new Date();
|
||||
} else if (nextStatus === "draft") {
|
||||
publishedAt = null;
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(postsTable)
|
||||
.set({
|
||||
...(data.slug !== undefined ? { slug: data.slug } : {}),
|
||||
...(data.title !== undefined ? { title: data.title } : {}),
|
||||
...(data.summary !== undefined ? { summary: data.summary } : {}),
|
||||
...(data.content !== undefined
|
||||
? {
|
||||
content: data.content,
|
||||
// 正文变了,阅读时长要重算
|
||||
readingMinutes: estimateReadingMinutes(data.content),
|
||||
}
|
||||
: {}),
|
||||
...(data.tags !== undefined ? { tags: data.tags } : {}),
|
||||
...(data.status !== undefined ? { status: data.status } : {}),
|
||||
...(data.cover !== undefined ? { cover: data.cover } : {}),
|
||||
...(data.description !== undefined
|
||||
? { description: data.description }
|
||||
: {}),
|
||||
publishedAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(postsTable.slug, slug))
|
||||
.returning();
|
||||
|
||||
return rows.length > 0 ? toPost(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** 删除文章;返回是否真的删除了 */
|
||||
export async function deletePost(slug: string): Promise<boolean> {
|
||||
const rows = await db
|
||||
.delete(postsTable)
|
||||
.where(eq(postsTable.slug, slug))
|
||||
.returning({ id: postsTable.id });
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
/** slug 是否已存在(可排除某个 slug,用于更新时判重) */
|
||||
export async function slugExists(
|
||||
slug: string,
|
||||
excludeSlug?: string,
|
||||
): Promise<boolean> {
|
||||
const rows = await db
|
||||
.select({ slug: postsTable.slug })
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (rows.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return rows[0].slug !== excludeSlug;
|
||||
}
|
||||
|
||||
/** 统计信息(首页展示用) */
|
||||
export async function getPostStats(): Promise<{
|
||||
published: number;
|
||||
drafts: number;
|
||||
tags: number;
|
||||
}> {
|
||||
const [publishedRows, draftRows, tags] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.status, "published")),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(postsTable)
|
||||
.where(eq(postsTable.status, "draft")),
|
||||
listTags(),
|
||||
]);
|
||||
|
||||
return {
|
||||
published: publishedRows[0]?.value ?? 0,
|
||||
drafts: draftRows[0]?.value ?? 0,
|
||||
tags: tags.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** 确保 slug 不存在,否则抛 409 */
|
||||
export async function assertSlugAvailable(
|
||||
slug: string,
|
||||
excludeSlug?: string,
|
||||
): Promise<void> {
|
||||
if (await slugExists(slug, excludeSlug)) {
|
||||
throw ApiError.conflict(`slug "${slug}" 已存在,请换一个`, [
|
||||
{ field: "slug", message: "该 slug 已被占用" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* 文章领域类型。
|
||||
*
|
||||
* 数据源已从 `content/posts/*.mdx` 文件迁移到 PostgreSQL 的 `posts` 表。
|
||||
* 为了不改动页面层与 MDX 渲染管线,这里保留了原有的字段命名
|
||||
* (`date`、`draft` 等),由 `lib/posts/repository.ts` 负责
|
||||
* 在数据库行与这些类型之间做映射。
|
||||
*/
|
||||
|
||||
/** 文章的公共元信息 */
|
||||
export interface PostFrontmatter {
|
||||
/** 标题 */
|
||||
title: string;
|
||||
/** 发布日期,格式 YYYY-MM-DD */
|
||||
date: string;
|
||||
/** 摘要,用于卡片与 SEO description */
|
||||
summary: string;
|
||||
/** 标签 */
|
||||
tags?: string[];
|
||||
/** 是否为草稿;为 true 时不参与任何列表、详情与 sitemap 输出 */
|
||||
draft?: boolean;
|
||||
/** 封面图路径 */
|
||||
cover?: string;
|
||||
/** 覆盖默认 SEO 描述 */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** 列表页使用的文章摘要信息(不含正文) */
|
||||
export interface PostMeta extends PostFrontmatter {
|
||||
/** URL 标识 */
|
||||
slug: string;
|
||||
/** 阅读时长(分钟,取整,最少 1) */
|
||||
readingMinutes: number;
|
||||
}
|
||||
|
||||
/** 详情页使用的完整文章 */
|
||||
export interface Post extends PostMeta {
|
||||
/** Markdown / MDX 正文(未编译) */
|
||||
content: string;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 站点级配置(单一事实来源)。
|
||||
*
|
||||
* 所有页面的 metadata、sitemap、robots、页头页脚都从这里取值,
|
||||
* 避免站点名称 / 域名散落在多个文件中。
|
||||
*/
|
||||
|
||||
export const siteConfig = {
|
||||
/** 站点名称 */
|
||||
name: "个人博客",
|
||||
/** 站点英文标识,用于 Logo 与 SEO */
|
||||
nameEn: "Inkwell",
|
||||
/** 一句话简介 */
|
||||
tagline: "记录工程实践与思考",
|
||||
/** 站点描述(用于首页 metadata 与 OG) */
|
||||
description:
|
||||
"一个使用 Next.js 构建的个人博客,记录前端工程、系统设计与日常思考。支持 Markdown / MDX 写作与代码高亮。",
|
||||
/** 作者信息 */
|
||||
author: {
|
||||
name: "站长",
|
||||
email: "hello@example.com",
|
||||
bio: "前端工程师,关注 Web 性能、开发者体验与设计系统。相信好的工具能让复杂的事情变得简单。",
|
||||
location: "中国 · 杭州",
|
||||
jobTitle: "前端工程师",
|
||||
},
|
||||
/** 社交 / 联系方式(关于页展示) */
|
||||
social: [
|
||||
{ label: "GitHub", href: "https://github.com", icon: "github" as const },
|
||||
{ label: "X / Twitter", href: "https://x.com", icon: "twitter" as const },
|
||||
{ label: "Email", href: "mailto:hello@example.com", icon: "mail" as const },
|
||||
],
|
||||
/** 部署后的站点地址,用于 sitemap / robots / 绝对 URL */
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL ?? "https://example.com",
|
||||
/** 默认 OG 图 */
|
||||
ogImage: "/og.svg",
|
||||
} as const;
|
||||
|
||||
export type SiteConfig = typeof siteConfig;
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
import "server-only";
|
||||
|
||||
/**
|
||||
* 从 MDX 原文中提取标题,生成目录(TOC)。
|
||||
*
|
||||
* 说明:这里用正则扫描 Markdown 标题而不是解析 AST——因为标题锚点 id
|
||||
* 由 rehype-slug 生成,其算法是 GitHub 风格的 slugify。为了与渲染结果
|
||||
* 完全一致,本模块实现了同一套规则(转小写、去标点、空格转连字符)。
|
||||
*
|
||||
* 只提取 h2 / h3:文档式阅读下超过三级的目录反而降低可扫读性。
|
||||
*/
|
||||
|
||||
export interface TocItem {
|
||||
/** 标题文本(已去除行内 Markdown 标记) */
|
||||
title: string;
|
||||
/** 与 rehype-slug 生成结果一致的锚点 id */
|
||||
id: string;
|
||||
/** 标题层级:2 或 3 */
|
||||
level: 2 | 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub 风格 slugify,与 rehype-slug 的默认行为保持一致。
|
||||
*
|
||||
* - 转小写
|
||||
* - 移除除字母、数字、空格、连字符、下划线、CJK 之外的字符
|
||||
* - 空格转连字符
|
||||
*/
|
||||
export function slugifyHeading(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\u2000-\u206F\u2E00-\u2E7F\\'!"#$%&()*+,./:;<=>?@[\]^`{|}~]/g, "")
|
||||
.replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
/** 去掉标题中的行内标记:**粗体**、`代码`、[链接](url) */
|
||||
function stripInlineMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // [文本](链接) → 文本
|
||||
.replace(/[*_`~]/g, "") // 强调与行内代码标记
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取目录项。
|
||||
*
|
||||
* 跳过代码块内的 `#` 注释,避免把代码里的注释误当作标题。
|
||||
*/
|
||||
export function extractToc(source: string): TocItem[] {
|
||||
const items: TocItem[] = [];
|
||||
const slugCounter = new Map<string, number>();
|
||||
|
||||
let insideCodeFence = false;
|
||||
let fenceMarker = "";
|
||||
|
||||
for (const rawLine of source.split("\n")) {
|
||||
const line = rawLine.trimEnd();
|
||||
|
||||
// 跟踪代码块边界(``` 或 ~~~)
|
||||
const fenceMatch = /^\s*(`{3,}|~{3,})/.exec(line);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1][0];
|
||||
|
||||
if (!insideCodeFence) {
|
||||
insideCodeFence = true;
|
||||
fenceMarker = marker;
|
||||
} else if (marker === fenceMarker) {
|
||||
insideCodeFence = false;
|
||||
fenceMarker = "";
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (insideCodeFence) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// ATX 标题:## 标题 / ### 标题
|
||||
const headingMatch = /^(#{2,3})\s+(.+?)\s*#*\s*$/.exec(line);
|
||||
if (!headingMatch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const level = headingMatch[1].length as 2 | 3;
|
||||
const title = stripInlineMarkdown(headingMatch[2]);
|
||||
|
||||
if (!title) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 处理重复标题:rehype-slug 会为同名标题追加 -1、-2 …
|
||||
const baseId = slugifyHeading(title);
|
||||
const seen = slugCounter.get(baseId) ?? 0;
|
||||
slugCounter.set(baseId, seen + 1);
|
||||
const id = seen === 0 ? baseId : `${baseId}-${seen}`;
|
||||
|
||||
items.push({ title, id, level });
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
/** 合并 className:clsx 处理条件类,tailwind-merge 消解冲突的 Tailwind 类 */
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/** 将日期格式化为中文可读形式,如 2025 年 3 月 8 日 */
|
||||
export function formatDate(date: string, locale = "zh-CN"): string {
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
}).format(new Date(`${date}T00:00:00Z`));
|
||||
}
|
||||
|
||||
/** 机器可读日期,用于 <time dateTime> */
|
||||
export function toDateTimeAttribute(date: string): string {
|
||||
return new Date(`${date}T00:00:00Z`).toISOString();
|
||||
}
|
||||
|
||||
/** 拼接绝对 URL,用于 canonical / OG / sitemap */
|
||||
export function absoluteUrl(base: string, pathname: string): string {
|
||||
const base$ = base.replace(/\/+$/, "");
|
||||
const path$ = pathname.startsWith("/") ? pathname : `/${pathname}`;
|
||||
return `${base$}${path$}`;
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* 文章的输入校验契约(单一事实来源)。
|
||||
*
|
||||
* API 路由、seed 脚本、未来的后台表单都复用这套 schema,
|
||||
* 避免"接口校验一套、脚本又一套"导致的不一致。
|
||||
*/
|
||||
|
||||
/** slug:小写字母/数字,用连字符分隔,与数据库 CHECK 约束保持一致 */
|
||||
export const slugSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "slug 不能为空")
|
||||
.max(200, "slug 最长 200 个字符")
|
||||
.regex(
|
||||
/^[a-z0-9]+(-[a-z0-9]+)*$/,
|
||||
"slug 只能包含小写字母、数字和连字符,且不能以连字符开头或结尾",
|
||||
);
|
||||
|
||||
/** 状态枚举,与数据库 CHECK 约束保持一致 */
|
||||
export const postStatusSchema = z.enum(["draft", "published"]);
|
||||
|
||||
/** 标签:去空白、去重、限制数量与长度 */
|
||||
export const tagsSchema = z
|
||||
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
|
||||
.max(10, "最多 10 个标签")
|
||||
.default([])
|
||||
.transform((tags) => [...new Set(tags)]);
|
||||
|
||||
/** 日期:接受 YYYY-MM-DD 或完整 ISO 字符串,统一转为 Date */
|
||||
export const dateInputSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), {
|
||||
message: "不是合法的日期",
|
||||
})
|
||||
.transform((value) => new Date(value));
|
||||
|
||||
/** 创建文章 */
|
||||
export const createPostSchema = z.object({
|
||||
slug: slugSchema,
|
||||
title: z.string().trim().min(1, "标题不能为空").max(200, "标题最长 200 个字符"),
|
||||
summary: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "摘要不能为空")
|
||||
.max(500, "摘要最长 500 个字符"),
|
||||
content: z.string().min(1, "正文不能为空"),
|
||||
tags: tagsSchema,
|
||||
status: postStatusSchema.default("draft"),
|
||||
/** 仅当 status=published 时有意义;不传则用当前时间 */
|
||||
publishedAt: dateInputSchema.optional(),
|
||||
cover: z.string().trim().min(1).nullish(),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 更新文章:所有字段可选,但至少要传一个。
|
||||
*
|
||||
* ⚠️ `.partial()` 的陷阱:它只把字段变成可选,**不会移除 `.default()`**。
|
||||
* `createPostSchema` 里 `tags` 有 `.default([])`、`status` 有 `.default("draft")`,
|
||||
* 于是 `PATCH {}` 会被解析成 `{ tags: [], status: "draft" }`,
|
||||
* 既绕过了"至少传一个字段"的校验,又会在用户只想改标题时
|
||||
* 意外把文章状态重置为草稿。
|
||||
*
|
||||
* 因此这里基于 **字段定义** 重建 schema:所有字段都显式声明为 `.optional()`,
|
||||
* 不带任何默认值。未传 = 不修改。
|
||||
*/
|
||||
export const updatePostSchema = z
|
||||
.object({
|
||||
slug: slugSchema.optional(),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "标题不能为空")
|
||||
.max(200, "标题最长 200 个字符")
|
||||
.optional(),
|
||||
summary: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "摘要不能为空")
|
||||
.max(500, "摘要最长 500 个字符")
|
||||
.optional(),
|
||||
content: z.string().min(1, "正文不能为空").optional(),
|
||||
tags: z
|
||||
.array(z.string().trim().min(1).max(40, "单个标签最长 40 个字符"))
|
||||
.max(10, "最多 10 个标签")
|
||||
.transform((tags) => [...new Set(tags)])
|
||||
.optional(),
|
||||
status: postStatusSchema.optional(),
|
||||
publishedAt: dateInputSchema.optional(),
|
||||
cover: z.string().trim().min(1).nullish(),
|
||||
description: z.string().trim().max(500).nullish(),
|
||||
})
|
||||
// 至少要有一个"真正传了值"的键(nullish 字段传 null 也算显式修改)
|
||||
.refine((data) => Object.keys(data).length > 0, {
|
||||
message: "至少需要提供一个要更新的字段",
|
||||
});
|
||||
|
||||
/** 列表查询参数 */
|
||||
export const listPostsQuerySchema = z.object({
|
||||
/** 返回数量,1–100 */
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
/** 偏移量,用于分页 */
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
/** 按标签过滤 */
|
||||
tag: z.string().trim().min(1).optional(),
|
||||
/** 按状态过滤;不传则默认只返回已发布 */
|
||||
status: postStatusSchema.optional(),
|
||||
/** 是否包含草稿(仅用于内部/管理场景) */
|
||||
includeDrafts: z
|
||||
.enum(["true", "false"])
|
||||
.default("false")
|
||||
.transform((value) => value === "true"),
|
||||
});
|
||||
|
||||
export type CreatePostInput = z.infer<typeof createPostSchema>;
|
||||
export type UpdatePostInput = z.infer<typeof updatePostSchema>;
|
||||
export type ListPostsQuery = z.infer<typeof listPostsQuerySchema>;
|
||||
|
||||
/**
|
||||
* 估算阅读时长(分钟)。
|
||||
* 中文按字符计(约 350 字/分钟),英文按单词计(约 200 词/分钟)。
|
||||
*
|
||||
* 写入时计算并缓存到 reading_minutes 列,避免列表页反复解析正文。
|
||||
* 与 `lib/posts/reading-time.ts` 共用同一实现,保证行为一致。
|
||||
*/
|
||||
export function estimateReadingMinutes(content: string): number {
|
||||
const chineseCharacters = content.match(/[\u4e00-\u9fa5]/g)?.length ?? 0;
|
||||
const latinWords =
|
||||
content
|
||||
.replace(/[\u4e00-\u9fa5]/g, " ")
|
||||
.match(/[A-Za-z0-9]+/g)?.length ?? 0;
|
||||
|
||||
const minutes = chineseCharacters / 350 + latinWords / 200;
|
||||
return Math.max(1, Math.round(minutes));
|
||||
}
|
||||
Reference in new issue
Block a user