Compare commits

...

3 Commits

40 changed files with 5575 additions and 24 deletions

1
Jenkinsfile vendored
View File

@ -33,6 +33,7 @@ def requiredRuntimeEnvKeys() {
'DB_PASSWORD', 'DB_PASSWORD',
'DB_DATABASE', 'DB_DATABASE',
'ADMIN_TOKEN_SECRET', 'ADMIN_TOKEN_SECRET',
'WORDPRESS_BASE_URL',
'FFLOGS_CLIENT_ID', 'FFLOGS_CLIENT_ID',
'FFLOGS_CLIENT_SECRET', 'FFLOGS_CLIENT_SECRET',
] ]

View File

@ -7,7 +7,7 @@ metadata:
app: kt-template-online-api app: kt-template-online-api
spec: spec:
replicas: 1 replicas: 1
revisionHistoryLimit: 5 revisionHistoryLimit: 3
strategy: strategy:
type: RollingUpdate type: RollingUpdate
rollingUpdate: rollingUpdate:

View File

@ -45,9 +45,19 @@
"pino-http": "^11.0.0", "pino-http": "^11.0.0",
"pino-loki": "^3.0.0", "pino-loki": "^3.0.0",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-remark": "^10.0.1",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"remark-stringify": "^11.0.0",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"svg-captcha": "^1.4.0", "svg-captcha": "^1.4.0",
"typeorm": "^0.3.28", "typeorm": "^0.3.28",
"unified": "^11.0.5",
"ws": "^8.21.0" "ws": "^8.21.0"
}, },
"devDependencies": { "devDependencies": {

File diff suppressed because it is too large Load Diff

58
sql/blog-init.sql Normal file
View File

@ -0,0 +1,58 @@
-- Blog 初始化 SQL
-- 用途:补齐本地 Markdown 博客文章、分类和标签表结构。
-- 说明:生产环境建议关闭 DB_SYNC 后导入本文件;本文件不会清空已有数据。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
CREATE TABLE IF NOT EXISTS `blog_article` (
`id` bigint NOT NULL,
`title` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL DEFAULT '',
`status` varchar(32) NOT NULL DEFAULT 'draft',
`excerpt` text DEFAULT NULL,
`content_markdown` mediumtext DEFAULT NULL,
`content_html` mediumtext DEFAULT NULL,
`cover` text DEFAULT NULL,
`author_name` varchar(255) NOT NULL DEFAULT 'KwiTsukasa',
`category_items` text DEFAULT NULL,
`tag_items` text DEFAULT NULL,
`views` int NOT NULL DEFAULT 0,
`comments` int NOT NULL DEFAULT 0,
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`publish_time` datetime DEFAULT NULL,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_blog_article_slug` (`slug`),
KEY `idx_blog_article_status` (`status`),
KEY `idx_blog_article_publish_time` (`publish_time`),
KEY `idx_blog_article_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `blog_term` (
`id` bigint NOT NULL,
`kind` varchar(32) NOT NULL,
`name` varchar(255) NOT NULL,
`slug` varchar(255) NOT NULL DEFAULT '',
`description` text DEFAULT NULL,
`parent_id` varchar(64) DEFAULT NULL,
`is_deleted` tinyint(1) NOT NULL DEFAULT 0,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
KEY `idx_blog_term_kind_slug` (`kind`, `slug`),
KEY `idx_blog_term_parent_id` (`parent_id`),
KEY `idx_blog_term_is_deleted` (`is_deleted`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `blog_theme_config` (
`id` varchar(64) NOT NULL,
`config` longtext NOT NULL,
`source` varchar(255) NOT NULL DEFAULT 'local',
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
`update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET FOREIGN_KEY_CHECKS = 1;

43
sql/blog-menu.sql Normal file
View File

@ -0,0 +1,43 @@
-- 增量补齐本地博客管理菜单。
-- 用途:已有库不需要重跑完整 vben-admin-init.sql 时,可单独执行本文件。
SET NAMES utf8mb4;
INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`)
VALUES
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100),
(2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0),
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000120304, 2041700000000100301, 'BlogArticleImport', NULL, NULL, NULL, 'Blog:Article:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120313, 2041700000000100302, 'BlogCategoryDelete', NULL, NULL, NULL, 'Blog:Category:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100303, 2041700000000100300, 'BlogTag', '/blog/tag', '/blog/tag/list', NULL, 'Blog:Tag:List', 'menu', '{"icon":"lucide:tags","title":"标签管理"}', 1, 2),
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100304, 2041700000000100300, 'BlogTheme', '/blog/theme', '/blog/theme/config', NULL, 'Blog:Theme:List', 'menu', '{"icon":"lucide:palette","title":"主题配置"}', 1, 3),
(2041700000000120331, 2041700000000100304, 'BlogThemeSave', NULL, NULL, NULL, 'Blog:Theme:Save', 'button', '{"title":"保存配置"}', 1, 0),
(2041700000000120332, 2041700000000100304, 'BlogThemeImport', NULL, NULL, NULL, 'Blog:Theme:Import', 'button', '{"title":"导入 WordPress"}', 1, 1)
ON DUPLICATE KEY UPDATE
`pid` = VALUES(`pid`),
`path` = VALUES(`path`),
`component` = VALUES(`component`),
`redirect` = VALUES(`redirect`),
`auth_code` = VALUES(`auth_code`),
`type` = VALUES(`type`),
`meta` = VALUES(`meta`),
`status` = VALUES(`status`),
`sort` = VALUES(`sort`),
`is_deleted` = 0;
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
SELECT role.`id`, menu.`id`
FROM `admin_role` role
JOIN `admin_menu` menu ON menu.`name` LIKE 'Blog%'
WHERE role.`role_code` IN ('super', 'admin')
AND role.`is_deleted` = 0
AND menu.`is_deleted` = 0;

View File

@ -189,6 +189,7 @@ VALUES
(2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120302, 2041700000000100301, 'BlogArticleEdit', NULL, NULL, NULL, 'Blog:Article:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000120303, 2041700000000100301, 'BlogArticleDelete', NULL, NULL, NULL, 'Blog:Article:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000120304, 2041700000000100301, 'BlogArticleImport', NULL, NULL, NULL, 'Blog:Article:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
(2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1), (2041700000000100302, 2041700000000100300, 'BlogCategory', '/blog/category', '/blog/category/list', NULL, 'Blog:Category:List', 'menu', '{"icon":"lucide:folder-tree","title":"分类管理"}', 1, 1),
(2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120311, 2041700000000100302, 'BlogCategoryCreate', NULL, NULL, NULL, 'Blog:Category:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120312, 2041700000000100302, 'BlogCategoryEdit', NULL, NULL, NULL, 'Blog:Category:Edit', 'button', '{"title":"common.edit"}', 1, 0),
@ -197,6 +198,9 @@ VALUES
(2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120321, 2041700000000100303, 'BlogTagCreate', NULL, NULL, NULL, 'Blog:Tag:Create', 'button', '{"title":"common.create"}', 1, 0),
(2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120322, 2041700000000100303, 'BlogTagEdit', NULL, NULL, NULL, 'Blog:Tag:Edit', 'button', '{"title":"common.edit"}', 1, 0),
(2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000120323, 2041700000000100303, 'BlogTagDelete', NULL, NULL, NULL, 'Blog:Tag:Delete', 'button', '{"title":"common.delete"}', 1, 0),
(2041700000000100304, 2041700000000100300, 'BlogTheme', '/blog/theme', '/blog/theme/config', NULL, 'Blog:Theme:List', 'menu', '{"icon":"lucide:palette","title":"主题配置"}', 1, 3),
(2041700000000120331, 2041700000000100304, 'BlogThemeSave', NULL, NULL, NULL, 'Blog:Theme:Save', 'button', '{"title":"保存配置"}', 1, 0),
(2041700000000120332, 2041700000000100304, 'BlogThemeImport', NULL, NULL, NULL, 'Blog:Theme:Import', 'button', '{"title":"导入 WordPress"}', 1, 1),
(2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998), (2041700000000100009, 0, 'Project', '/vben-admin', NULL, NULL, NULL, 'catalog', '{"badgeType":"dot","icon":"carbon:data-center","order":9998,"title":"demos.vben.title"}', 1, 9998),
(2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0), (2041700000000100901, 2041700000000100009, 'VbenDocument', '/vben-admin/document', 'IFrameView', NULL, NULL, 'embedded', '{"icon":"carbon:book","iframeSrc":"https://doc.vben.pro","title":"demos.vben.document"}', 1, 0),
(2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0), (2041700000000100902, 2041700000000100009, 'VbenGithub', '/vben-admin/github', 'IFrameView', NULL, NULL, 'link', '{"icon":"carbon:logo-github","link":"https://github.com/vbenjs/vue-vben-admin","title":"Github"}', 1, 0),

View File

@ -15,6 +15,7 @@ import {
SaveBodyInterceptor, SaveBodyInterceptor,
} from './common'; } from './common';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { BlogModule } from './blog/blog.module';
import { WordpressModule } from './wordpress/wordpress.module'; import { WordpressModule } from './wordpress/wordpress.module';
import { QqbotModule } from './qqbot/qqbot.module'; import { QqbotModule } from './qqbot/qqbot.module';
@ -64,6 +65,7 @@ import { QqbotModule } from './qqbot/qqbot.module';
MinioClientModule, MinioClientModule,
CommonModule, CommonModule,
AdminModule, AdminModule,
BlogModule,
WordpressModule, WordpressModule,
QqbotModule, QqbotModule,
], ],

View File

@ -0,0 +1,135 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { Public, vbenSuccess } from '@/common';
import {
BlogArticleBodyDto,
BlogArticleImportWordpressDto,
BlogArticleListQueryDto,
BlogArticleTermOptionsQueryDto,
BlogArticleUpdateBodyDto,
} from './blog-article.dto';
import { BlogArticleService } from './blog-article.service';
@ApiTags('Blog - 文章')
@Controller('blog/article')
@UseGuards(JwtAuthGuard)
export class BlogArticleController {
constructor(private readonly blogArticleService: BlogArticleService) {}
@Get('public/list')
@Public()
@ApiOperation({ summary: '获取公开博客文章分页列表' })
async publicList(@Res() res, @Query() query: BlogArticleListQueryDto) {
const list = await this.blogArticleService.publicList(query);
return res.send(vbenSuccess(list));
}
@Get('public/detail')
@Public()
@ApiOperation({ summary: '获取公开博客文章详情' })
@ApiQuery({ name: 'slug', required: false, type: String })
@ApiQuery({ name: 'id', required: false, type: String })
async publicDetail(
@Res() res,
@Query('slug') slug?: string,
@Query('id') id?: string,
) {
const detail = await this.blogArticleService.publicDetail({
id,
slug,
});
return res.send(vbenSuccess(detail));
}
@Get('list')
@ApiOperation({ summary: '获取博客文章分页列表' })
async list(@Res() res, @Query() query: BlogArticleListQueryDto) {
const list = await this.blogArticleService.page(query);
return res.send(vbenSuccess(list));
}
@Get('detail')
@ApiOperation({ summary: '获取博客文章详情' })
@ApiQuery({ name: 'id', type: String })
async detail(@Res() res, @Query('id') id: string) {
const detail = await this.blogArticleService.detail(id);
return res.send(vbenSuccess(detail));
}
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增博客文章' })
async save(@Res() res, @Body() body: BlogArticleBodyDto) {
const result = await this.blogArticleService.save(body);
return res.send(vbenSuccess(result));
}
@Post('update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑博客文章' })
async update(@Res() res, @Body() body: BlogArticleUpdateBodyDto) {
const result = await this.blogArticleService.update(body);
return res.send(vbenSuccess(result));
}
@Post('remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除博客文章' })
@ApiQuery({ name: 'id', type: String })
async remove(@Res() res, @Query('id') id: string) {
const result = await this.blogArticleService.remove(id);
return res.send(vbenSuccess(result));
}
@Get('category-options')
@ApiOperation({ summary: '获取本地博客文章分类选项' })
async categoryOptions(
@Res() res,
@Query() query: BlogArticleTermOptionsQueryDto,
) {
const result = await this.blogArticleService.categoryOptions(query);
return res.send(vbenSuccess(result));
}
@Get('tag-options')
@ApiOperation({ summary: '获取本地博客文章标签选项' })
async tagOptions(
@Res() res,
@Query() query: BlogArticleTermOptionsQueryDto,
) {
const result = await this.blogArticleService.tagOptions(query);
return res.send(vbenSuccess(result));
}
@Post('import-wordpress')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '从 WordPress 导入文章到本地博客' })
async importWordpress(
@Res() res,
@Body() body: BlogArticleImportWordpressDto,
) {
const result = await this.blogArticleService.importFromWordpress(body);
return res.send(vbenSuccess(result));
}
}

View File

@ -0,0 +1,120 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { BlogArticleStatus, BlogArticleTerm } from './blog-article.entity';
export class BlogArticleListQueryDto {
@ApiPropertyOptional({
default: 1,
type: Number,
})
pageNo?: number;
@ApiPropertyOptional({
default: 10,
type: Number,
})
pageSize?: number;
@ApiPropertyOptional()
search?: string;
@ApiPropertyOptional({
enum: ['draft', 'pending', 'private', 'publish'],
})
status?: BlogArticleStatus | 'any';
@ApiPropertyOptional({
description: '分类 slug、名称或逗号分隔值',
})
categories?: string | string[];
@ApiPropertyOptional({
description: '标签 slug、名称或逗号分隔值',
})
tags?: string | string[];
}
export class BlogArticleBodyDto {
@ApiProperty()
title: string;
@ApiPropertyOptional()
slug?: string;
@ApiPropertyOptional({
enum: ['draft', 'pending', 'private', 'publish'],
})
status?: BlogArticleStatus;
@ApiPropertyOptional()
excerpt?: string;
@ApiPropertyOptional()
content?: string;
@ApiPropertyOptional({
enum: ['html', 'markdown'],
})
contentFormat?: 'html' | 'markdown';
@ApiPropertyOptional()
cover?: string;
@ApiPropertyOptional()
authorName?: string;
@ApiPropertyOptional()
categories?: Array<BlogArticleTerm | string>;
@ApiPropertyOptional()
tags?: Array<BlogArticleTerm | string>;
}
export class BlogArticleUpdateBodyDto extends BlogArticleBodyDto {
@ApiProperty()
id: string;
}
export class BlogArticleImportWordpressDto {
@ApiPropertyOptional({
default: true,
description: '是否按 WordPress total 分页导入全部文章',
})
all?: boolean;
@ApiPropertyOptional({
default: 1,
type: Number,
})
pageNo?: number;
@ApiPropertyOptional({
default: 100,
type: Number,
})
pageSize?: number;
@ApiPropertyOptional({
default: false,
description: '已存在同 slug 文章时是否覆盖',
})
overwrite?: boolean;
}
export class BlogArticleTermOptionsQueryDto {
@ApiPropertyOptional({
default: 1,
type: Number,
})
pageNo?: number;
@ApiPropertyOptional({
default: 200,
type: Number,
})
pageSize?: number;
@ApiPropertyOptional({
description: '分类或标签关键词',
})
search?: string;
}

View File

@ -0,0 +1,159 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
export type BlogArticleStatus = 'draft' | 'pending' | 'private' | 'publish';
export type BlogArticleTerm = {
count?: number;
id?: string;
name: string;
slug: string;
};
@Entity('blog_article')
export class BlogArticle {
@ApiPropertyOptional()
@PrimaryColumn({
type: 'bigint',
})
id: string;
@ApiProperty()
@Column()
title: string;
@ApiPropertyOptional()
@Column({
default: '',
})
slug: string;
@ApiPropertyOptional({
enum: ['draft', 'pending', 'private', 'publish'],
})
@Column({
default: 'draft',
})
status: BlogArticleStatus;
@ApiPropertyOptional()
@Column({
type: 'text',
nullable: true,
})
excerpt: string;
@ApiPropertyOptional()
@Column({
name: 'content_markdown',
type: 'mediumtext',
nullable: true,
})
contentMarkdown: string;
@ApiPropertyOptional()
@Column({
name: 'content_html',
type: 'mediumtext',
nullable: true,
})
contentHtml: string;
@ApiPropertyOptional()
@Column({
type: 'text',
nullable: true,
})
cover: string;
@ApiPropertyOptional()
@Column({
name: 'author_name',
default: 'KwiTsukasa',
})
authorName: string;
@ApiPropertyOptional()
@Column({
name: 'category_items',
type: 'simple-json',
nullable: true,
})
categoryItems: BlogArticleTerm[];
@ApiPropertyOptional()
@Column({
name: 'tag_items',
type: 'simple-json',
nullable: true,
})
tagItems: BlogArticleTerm[];
@ApiPropertyOptional()
@Column({
default: 0,
})
views: number;
@ApiPropertyOptional()
@Column({
default: 0,
})
comments: number;
@ApiPropertyOptional()
@Column({
default: false,
name: 'is_deleted',
})
isDeleted: boolean;
@ApiPropertyOptional()
@Column({
name: 'publish_time',
nullable: true,
type: 'datetime',
})
@FormatDateTime()
publishTime: Date;
@CreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
@UpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
@ApiPropertyOptional()
categories?: string[];
@ApiPropertyOptional()
tags?: string[];
@ApiPropertyOptional()
categoriesResolved?: BlogArticleTerm[];
@ApiPropertyOptional()
tagsResolved?: BlogArticleTerm[];
@ApiPropertyOptional()
excerptText?: string;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,450 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Brackets, Repository } from 'typeorm';
import { MarkdownService, throwVbenError, ToolsService } from '@/common';
import {
BlogArticle,
type BlogArticleStatus,
type BlogArticleTerm,
} from './blog-article.entity';
import type {
BlogArticleBodyDto,
BlogArticleImportWordpressDto,
BlogArticleListQueryDto,
BlogArticleTermOptionsQueryDto,
BlogArticleUpdateBodyDto,
} from './blog-article.dto';
import { WordpressService } from '@/wordpress/wordpress.service';
import { BlogTermService } from './blog-term.service';
@Injectable()
export class BlogArticleService {
constructor(
@InjectRepository(BlogArticle)
private readonly articleRepository: Repository<BlogArticle>,
private readonly markdownService: MarkdownService,
private readonly toolsService: ToolsService,
private readonly wordpressService: WordpressService,
private readonly blogTermService: BlogTermService,
) {}
async page(query: BlogArticleListQueryDto) {
return this.queryPage(query);
}
async publicList(query: BlogArticleListQueryDto) {
return this.queryPage({
...query,
status: 'publish',
});
}
async detail(id: string | number) {
const article = await this.articleRepository.findOne({
where: {
id: `${id}`,
isDeleted: false,
},
});
if (!article) {
throwVbenError('文章不存在', HttpStatus.NOT_FOUND);
}
return this.toResponse(article);
}
async publicDetail(query: { id?: string; slug?: string }) {
const article = await this.articleRepository.findOne({
where: query.id
? {
id: `${query.id}`,
isDeleted: false,
status: 'publish',
}
: {
isDeleted: false,
slug: this.normalizeSlug(query.slug || ''),
status: 'publish',
},
});
if (!article) {
throwVbenError('文章不存在或未发布', HttpStatus.NOT_FOUND);
}
return this.toResponse(article);
}
async save(body: BlogArticleBodyDto) {
const articleEntity = await this.getArticleEntity(body);
await this.syncArticleTerms(articleEntity);
const article = this.articleRepository.create(articleEntity);
const saved = await this.articleRepository.save(article);
return this.toResponse(saved);
}
async update(body: BlogArticleUpdateBodyDto) {
const article = await this.articleRepository.findOne({
where: {
id: `${body.id}`,
isDeleted: false,
},
});
if (!article) {
throwVbenError('文章不存在', HttpStatus.NOT_FOUND);
}
const articleEntity = await this.getArticleEntity(body, article);
await this.syncArticleTerms(articleEntity);
Object.assign(article, articleEntity);
const saved = await this.articleRepository.save(article);
return this.toResponse(saved);
}
async remove(id: string | number) {
const result = await this.articleRepository.update(
{
id: `${id}`,
isDeleted: false,
},
{
isDeleted: true,
},
);
return (result.affected || 0) > 0;
}
async categoryOptions(query: BlogArticleTermOptionsQueryDto = {}) {
return this.blogTermService.options('category', query);
}
async tagOptions(query: BlogArticleTermOptionsQueryDto = {}) {
return this.blogTermService.options('tag', query);
}
async importFromWordpress(query: BlogArticleImportWordpressDto = {}) {
const pageNo = this.toolsService.toPositiveNumber(query.pageNo, 1);
const pageSize = Math.min(
this.toolsService.toPositiveNumber(query.pageSize, 100),
100,
);
const importAll = this.toolsService.normalizeBoolean(query.all, true);
const overwrite = this.toolsService.normalizeBoolean(query.overwrite);
const firstWordpressPage = await this.wordpressService.publicArticleList({
pageNo,
pageSize,
});
const result = {
created: 0,
items: [] as Array<{
action: 'created' | 'skipped' | 'updated';
id: string;
slug: string;
title: string;
}>,
pageCount: 0,
skipped: 0,
total: firstWordpressPage.total || firstWordpressPage.list?.length || 0,
updated: 0,
};
const wordpressPages = [firstWordpressPage];
if (importAll && firstWordpressPage.total > pageSize) {
const totalPages = Math.ceil(firstWordpressPage.total / pageSize);
for (
let currentPageNo = pageNo + 1;
currentPageNo <= totalPages;
currentPageNo += 1
) {
wordpressPages.push(
await this.wordpressService.publicArticleList({
pageNo: currentPageNo,
pageSize,
}),
);
}
}
for (const wordpressPage of wordpressPages) {
result.pageCount += 1;
for (const source of wordpressPage.list as Array<Record<string, any>>) {
const detail = (await this.wordpressService.publicArticleDetail({
id: source.id,
slug: source.slug,
})) as Record<string, any>;
const importEntity = await this.getWordpressImportEntity(detail);
await this.syncArticleTerms(importEntity);
const existing = await this.articleRepository.findOne({
where: {
isDeleted: false,
slug: importEntity.slug,
},
});
if (existing && !overwrite) {
result.skipped += 1;
result.items.push({
action: 'skipped',
id: existing.id,
slug: existing.slug,
title: existing.title,
});
continue;
}
const saved = await this.articleRepository.save(
existing
? Object.assign(existing, importEntity)
: this.articleRepository.create(importEntity),
);
const action = existing ? 'updated' : 'created';
result[action] += 1;
result.items.push({
action,
id: saved.id,
slug: saved.slug,
title: saved.title,
});
}
}
return result;
}
private async queryPage(query: BlogArticleListQueryDto) {
const { pageSize, skip } = this.toolsService.getPageParams(query);
const builder = this.articleRepository
.createQueryBuilder('article')
.where('article.isDeleted = :isDeleted', { isDeleted: false });
const status = query.status || 'any';
const categoryKeywords = this.normalizeQueryList(query.categories);
const tagKeywords = this.normalizeQueryList(query.tags);
if (status !== 'any') {
builder.andWhere('article.status = :status', { status });
}
if (query.search) {
builder.andWhere(
new Brackets((qb) => {
qb.where('article.title LIKE :search', {
search: `%${query.search}%`,
})
.orWhere('article.excerpt LIKE :search', {
search: `%${query.search}%`,
})
.orWhere('article.contentMarkdown LIKE :search', {
search: `%${query.search}%`,
});
}),
);
}
categoryKeywords.forEach((keyword, index) => {
builder.andWhere(`article.categoryItems LIKE :category${index}`, {
[`category${index}`]: `%${keyword}%`,
});
});
tagKeywords.forEach((keyword, index) => {
builder.andWhere(`article.tagItems LIKE :tag${index}`, {
[`tag${index}`]: `%${keyword}%`,
});
});
const [list, total] = await builder
.orderBy('article.publishTime', 'DESC')
.addOrderBy('article.updateTime', 'DESC')
.skip(skip)
.take(pageSize)
.getManyAndCount();
return this.toolsService.page(list.map((item) => this.toResponse(item)), total);
}
private async getArticleEntity(
body: BlogArticleBodyDto,
current?: BlogArticle,
): Promise<Partial<BlogArticle>> {
const contentFormat = body.contentFormat || 'markdown';
const nextArticle: Partial<BlogArticle> = this.toolsService.pickDefined({
authorName: body.authorName,
cover: body.cover,
excerpt: body.excerpt,
slug: this.normalizeSlug(body.slug || body.title || current?.slug || ''),
status: body.status || current?.status || ('draft' as BlogArticleStatus),
title: body.title,
});
if (body.categories) {
nextArticle.categoryItems = this.normalizeTerms(body.categories);
}
if (body.tags) {
nextArticle.tagItems = this.normalizeTerms(body.tags);
}
if (body.content !== undefined) {
if (contentFormat === 'markdown') {
nextArticle.contentMarkdown = body.content;
nextArticle.contentHtml = await this.markdownService.renderToHtml(
body.content,
);
} else {
nextArticle.contentMarkdown = current?.contentMarkdown || '';
nextArticle.contentHtml = await this.markdownService.renderToHtml(
body.content,
);
}
}
if (
nextArticle.status === 'publish' &&
!current?.publishTime &&
!nextArticle.publishTime
) {
nextArticle.publishTime = new Date();
}
return nextArticle;
}
private normalizeTerms(values: Array<BlogArticleTerm | string>) {
const seen = new Set<string>();
return values
.map((item) => {
const name =
typeof item === 'string'
? this.toolsService.toTrimmedString(item)
: this.toolsService.toTrimmedString(item.name);
const slug =
typeof item === 'string'
? this.normalizeSlug(item)
: this.normalizeSlug(item.slug || item.name);
return this.toolsService.pickDefined({
id: typeof item === 'string' ? undefined : item.id,
name,
slug,
}) as BlogArticleTerm;
})
.filter((item) => {
if (!item.name || seen.has(item.slug)) return false;
seen.add(item.slug);
return true;
});
}
private async getWordpressImportEntity(source: Record<string, any>) {
const rawContentHtml = source.contentHtml || source.content?.rendered || '';
const contentHtml = await this.markdownService.sanitizeHtml(rawContentHtml);
const contentMarkdown =
source.contentMarkdown ||
this.markdownService.extractSource(source.content?.raw) ||
(await this.markdownService.renderHtmlToMarkdown(rawContentHtml));
const status = (source.status === 'publish'
? 'publish'
: 'draft') as BlogArticleStatus;
return this.toolsService.pickDefined({
authorName: source.authorName,
categoryItems: this.normalizeTerms(
(source.categoriesResolved || []).map((item) => ({
id: item.id ? `${item.id}` : undefined,
name: item.name,
slug: item.slug || item.name,
})),
),
comments: Number(source.comment_count || 0),
contentHtml,
contentMarkdown,
cover: source.cover,
excerpt:
source.excerptText ||
this.stripHtml(source.excerpt?.rendered || source.excerpt || ''),
publishTime: this.parseDate(source.date || source.modified),
slug: this.normalizeSlug(source.slug || source.title?.rendered || ''),
status,
tagItems: this.normalizeTerms(
(source.tagsResolved || []).map((item) => ({
id: item.id ? `${item.id}` : undefined,
name: item.name,
slug: item.slug || item.name,
})),
),
title: this.stripHtml(source.title?.rendered || source.title || ''),
views: Number(source.views || 0),
});
}
private normalizeQueryList(value?: string | string[]) {
if (Array.isArray(value)) {
return value
.flatMap((item) => item.split(','))
.map((item) => this.normalizeSlug(item))
.filter(Boolean);
}
if (!value) return [];
return value
.split(',')
.map((item) => this.normalizeSlug(item))
.filter(Boolean);
}
private async syncArticleTerms(article: Partial<BlogArticle>) {
if (article.categoryItems) {
await this.blogTermService.syncTerms('category', article.categoryItems);
}
if (article.tagItems) {
await this.blogTermService.syncTerms('tag', article.tagItems);
}
}
private toResponse(article: BlogArticle) {
const categoriesResolved = article.categoryItems || [];
const tagsResolved = article.tagItems || [];
const excerptText =
article.excerpt ||
this.stripHtml(article.contentHtml || '').slice(0, 120);
return Object.assign(article, {
categories: categoriesResolved.map((item) => item.name),
categoriesResolved,
excerptText,
tags: tagsResolved.map((item) => item.name),
tagsResolved,
});
}
private normalizeSlug(value: string) {
return this.toolsService.normalizeSlugText(value);
}
private stripHtml(value: string) {
return value
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
private parseDate(value: unknown) {
if (!value) return null;
const date = new Date(`${value}`);
return Number.isNaN(date.getTime()) ? null : date;
}
}

View File

@ -0,0 +1,130 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common';
import type { BlogTermKind } from './blog-term.entity';
import {
BlogTermBodyDto,
BlogTermListQueryDto,
BlogTermUpdateBodyDto,
} from './blog-term.dto';
import { BlogTermService } from './blog-term.service';
@ApiTags('Blog - 分类标签')
@Controller('blog')
@UseGuards(JwtAuthGuard)
export class BlogTermController {
constructor(private readonly blogTermService: BlogTermService) {}
@Get('category/list')
@ApiOperation({ summary: '获取本地博客分类分页列表' })
async categoryList(@Res() res, @Query() query: BlogTermListQueryDto) {
const list = await this.blogTermService.page('category', query);
return res.send(vbenSuccess(list));
}
@Get('category/detail')
@ApiOperation({ summary: '获取本地博客分类详情' })
@ApiQuery({ name: 'id', type: String })
async categoryDetail(@Res() res, @Query('id') id: string) {
const detail = await this.blogTermService.detail('category', id);
return res.send(vbenSuccess(detail));
}
@Post('category/save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增本地博客分类' })
async categorySave(@Res() res, @Body() body: BlogTermBodyDto) {
const result = await this.blogTermService.save('category', body);
return res.send(vbenSuccess(result));
}
@Post('category/update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑本地博客分类' })
async categoryUpdate(@Res() res, @Body() body: BlogTermUpdateBodyDto) {
const result = await this.blogTermService.update('category', body);
return res.send(vbenSuccess(result));
}
@Post('category/remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除本地博客分类' })
@ApiQuery({ name: 'id', type: String })
async categoryRemove(@Res() res, @Query('id') id: string) {
const result = await this.blogTermService.remove('category', id);
return res.send(vbenSuccess(result));
}
@Get('tag/list')
@ApiOperation({ summary: '获取本地博客标签分页列表' })
async tagList(@Res() res, @Query() query: BlogTermListQueryDto) {
const list = await this.blogTermService.page('tag', query);
return res.send(vbenSuccess(list));
}
@Get('tag/detail')
@ApiOperation({ summary: '获取本地博客标签详情' })
@ApiQuery({ name: 'id', type: String })
async tagDetail(@Res() res, @Query('id') id: string) {
const detail = await this.blogTermService.detail('tag', id);
return res.send(vbenSuccess(detail));
}
@Post('tag/save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '新增本地博客标签' })
async tagSave(@Res() res, @Body() body: BlogTermBodyDto) {
const result = await this.blogTermService.save('tag', body);
return res.send(vbenSuccess(result));
}
@Post('tag/update')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '编辑本地博客标签' })
async tagUpdate(@Res() res, @Body() body: BlogTermUpdateBodyDto) {
const result = await this.blogTermService.update('tag', body);
return res.send(vbenSuccess(result));
}
@Post('tag/remove')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '删除本地博客标签' })
@ApiQuery({ name: 'id', type: String })
async tagRemove(@Res() res, @Query('id') id: string) {
const result = await this.blogTermService.remove('tag', id);
return res.send(vbenSuccess(result));
}
@Get('term/options')
@ApiOperation({ summary: '获取本地博客分类或标签选项' })
async options(
@Res() res,
@Query('kind') kind: BlogTermKind,
@Query() query: BlogTermListQueryDto,
) {
const result = await this.blogTermService.options(kind, query);
return res.send(vbenSuccess(result));
}
}

59
src/blog/blog-term.dto.ts Normal file
View File

@ -0,0 +1,59 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class BlogTermListQueryDto {
@ApiPropertyOptional({
default: 1,
type: Number,
})
pageNo?: number;
@ApiPropertyOptional({
default: 10,
type: Number,
})
pageSize?: number;
@ApiPropertyOptional({
description: '分类或标签关键词',
})
search?: string;
@ApiPropertyOptional({
description: '是否隐藏没有关联文章的分类或标签',
})
hide_empty?: boolean;
@ApiPropertyOptional({
description: '父级分类 ID仅分类模块使用',
})
parent?: string;
}
export class BlogTermBodyDto {
@ApiProperty({
description: '名称',
})
name: string;
@ApiPropertyOptional({
description: '别名',
})
slug?: string;
@ApiPropertyOptional({
description: '描述',
})
description?: string;
@ApiPropertyOptional({
description: '父级分类 ID仅分类模块使用',
})
parent?: string;
}
export class BlogTermUpdateBodyDto extends BlogTermBodyDto {
@ApiProperty({
description: '本地分类或标签 ID',
})
id: string;
}

View File

@ -0,0 +1,81 @@
import {
BeforeInsert,
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ensureSnowflakeId, FormatDateTime } from '@/common';
export type BlogTermKind = 'category' | 'tag';
@Entity('blog_term')
export class BlogTerm {
@ApiPropertyOptional()
@PrimaryColumn({
type: 'bigint',
})
id: string;
@ApiProperty({
enum: ['category', 'tag'],
})
@Column()
kind: BlogTermKind;
@ApiProperty()
@Column()
name: string;
@ApiPropertyOptional()
@Column({
default: '',
})
slug: string;
@ApiPropertyOptional()
@Column({
type: 'text',
nullable: true,
})
description: string;
@ApiPropertyOptional()
@Column({
name: 'parent_id',
nullable: true,
})
parentId: string;
@ApiPropertyOptional()
@Column({
default: false,
name: 'is_deleted',
})
isDeleted: boolean;
@CreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
@UpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
@ApiPropertyOptional()
count?: number;
@ApiPropertyOptional()
parent?: string;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,302 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import {
BlogArticle,
type BlogArticleTerm,
} from './blog-article.entity';
import { BlogTerm, type BlogTermKind } from './blog-term.entity';
import type {
BlogTermBodyDto,
BlogTermListQueryDto,
BlogTermUpdateBodyDto,
} from './blog-term.dto';
type CountedBlogTerm = BlogArticleTerm & {
count: number;
};
@Injectable()
export class BlogTermService {
constructor(
@InjectRepository(BlogTerm)
private readonly termRepository: Repository<BlogTerm>,
@InjectRepository(BlogArticle)
private readonly articleRepository: Repository<BlogArticle>,
private readonly toolsService: ToolsService,
) {}
async page(kind: BlogTermKind, query: BlogTermListQueryDto = {}) {
const { pageSize, skip } = this.toolsService.getPageParams(query);
const countMap = await this.collectArticleTermMap(kind);
const list = this.filterAndSortTerms(
(await this.getStoredTerms(kind)).map((term) =>
this.toResponse(term, countMap),
),
query,
);
return this.toolsService.page(list.slice(skip, skip + pageSize), list.length);
}
async options(kind: BlogTermKind, query: BlogTermListQueryDto = {}) {
const { pageSize, skip } = this.toolsService.getPageParams(query, 1, 200);
const countMap = await this.collectArticleTermMap(kind);
const termMap = new Map<string, BlogTerm>();
(await this.getStoredTerms(kind)).forEach((term) => {
termMap.set(term.slug || this.normalizeSlug(term.name), term);
});
countMap.forEach((term, slug) => {
if (termMap.has(slug)) return;
termMap.set(
slug,
this.termRepository.create({
id: term.id || slug,
kind,
name: term.name,
slug,
}),
);
});
const list = this.filterAndSortTerms(
Array.from(termMap.values()).map((term) => this.toResponse(term, countMap)),
query,
);
return this.toolsService.page(list.slice(skip, skip + pageSize), list.length);
}
async detail(kind: BlogTermKind, id: string | number) {
const term = await this.findExistingTerm(kind, id);
const countMap = await this.collectArticleTermMap(kind);
return this.toResponse(term, countMap);
}
async save(kind: BlogTermKind, body: BlogTermBodyDto) {
const entity = this.getTermEntity(kind, body);
await this.assertSlugAvailable(kind, entity.slug);
const saved = await this.termRepository.save(
this.termRepository.create(entity),
);
return this.toResponse(saved, await this.collectArticleTermMap(kind));
}
async update(kind: BlogTermKind, body: BlogTermUpdateBodyDto) {
const term = await this.findExistingTerm(kind, body.id);
const nextEntity = this.getTermEntity(kind, body);
await this.assertSlugAvailable(kind, nextEntity.slug, term.id);
Object.assign(term, nextEntity);
const saved = await this.termRepository.save(term);
return this.toResponse(saved, await this.collectArticleTermMap(kind));
}
async remove(kind: BlogTermKind, id: string | number) {
const result = await this.termRepository.update(
{
id: `${id}`,
isDeleted: false,
kind,
},
{
isDeleted: true,
},
);
return (result.affected || 0) > 0;
}
async syncTerms(kind: BlogTermKind, terms: BlogArticleTerm[] = []) {
for (const term of this.normalizeTerms(terms)) {
const slug = term.slug || this.normalizeSlug(term.name);
const existing = await this.termRepository.findOne({
where: {
isDeleted: false,
kind,
slug,
},
});
if (existing) continue;
await this.termRepository.save(
this.termRepository.create({
kind,
name: term.name,
slug,
}),
);
}
}
private async getStoredTerms(kind: BlogTermKind) {
return this.termRepository.find({
where: {
isDeleted: false,
kind,
},
});
}
private async findExistingTerm(kind: BlogTermKind, id: string | number) {
const term = await this.termRepository.findOne({
where: {
id: `${id}`,
isDeleted: false,
kind,
},
});
if (!term) {
throwVbenError('分类或标签不存在', HttpStatus.NOT_FOUND);
}
return term;
}
private getTermEntity(kind: BlogTermKind, body: BlogTermBodyDto) {
const name = this.toolsService.toTrimmedString(body.name);
if (!name) {
throwVbenError('请填写分类或标签名称', HttpStatus.BAD_REQUEST);
}
return {
description: body.description || '',
kind,
name,
parentId:
kind === 'category' ? this.toolsService.toStringId(body.parent) : '',
slug: this.normalizeSlug(body.slug || name),
} as Partial<BlogTerm>;
}
private async assertSlugAvailable(
kind: BlogTermKind,
slug: string,
currentId?: string,
) {
const existing = await this.termRepository.findOne({
where: {
isDeleted: false,
kind,
slug,
},
});
if (existing && existing.id !== currentId) {
throwVbenError('同名分类或标签已存在', HttpStatus.CONFLICT);
}
}
private async collectArticleTermMap(kind: BlogTermKind) {
const articles = await this.articleRepository.find({
select:
kind === 'category'
? {
categoryItems: true,
}
: {
tagItems: true,
},
where: {
isDeleted: false,
},
});
const termMap = new Map<string, CountedBlogTerm>();
articles.forEach((article) => {
const source =
kind === 'category' ? article.categoryItems || [] : article.tagItems || [];
this.normalizeTerms(source).forEach((term) => {
const slug = term.slug || this.normalizeSlug(term.name);
const current = termMap.get(slug);
if (current) {
current.count += 1;
return;
}
termMap.set(slug, {
...term,
count: 1,
id: term.id || slug,
slug,
});
});
});
return termMap;
}
private normalizeTerms(values: BlogArticleTerm[]) {
const seen = new Set<string>();
return values
.map((item) => {
const name = this.toolsService.toTrimmedString(item.name);
const slug = this.normalizeSlug(item.slug || item.name);
return this.toolsService.pickDefined({
id: item.id,
name,
slug,
}) as BlogArticleTerm;
})
.filter((item) => {
if (!item.name || seen.has(item.slug)) return false;
seen.add(item.slug);
return true;
});
}
private filterAndSortTerms(
terms: BlogTerm[],
query: BlogTermListQueryDto = {},
) {
const search = this.toolsService.toTrimmedString(query.search).toLowerCase();
const parent = this.toolsService.toStringId(query.parent);
const hideEmpty = this.toolsService.normalizeBoolean(query.hide_empty);
return terms
.filter((term) => {
if (hideEmpty && !term.count) return false;
if (parent && term.parent !== parent) return false;
if (!search) return true;
return `${term.name} ${term.slug} ${term.description || ''}`
.toLowerCase()
.includes(search);
})
.sort((left, right) => {
const countDiff = (right.count || 0) - (left.count || 0);
if (countDiff !== 0) return countDiff;
return left.name.localeCompare(right.name, 'zh-CN');
});
}
private toResponse(term: BlogTerm, countMap: Map<string, CountedBlogTerm>) {
const slug = term.slug || this.normalizeSlug(term.name);
const countedTerm = countMap.get(slug);
return Object.assign(term, {
count: countedTerm?.count || 0,
id: term.id || slug,
parent: term.parentId || undefined,
slug,
});
}
private normalizeSlug(value: unknown) {
return this.toolsService.normalizeSlugText(value);
}
}

View File

@ -0,0 +1,51 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Res,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { Public, vbenSuccess } from '@/common';
import { BlogThemeConfigBodyDto } from './blog-theme-config.dto';
import { BlogThemeConfigService } from './blog-theme-config.service';
@ApiTags('Blog - 主题')
@Controller('blog/theme')
@UseGuards(JwtAuthGuard)
export class BlogThemeConfigController {
constructor(
private readonly blogThemeConfigService: BlogThemeConfigService,
) {}
@Get('config')
@Public()
@ApiOperation({ summary: '获取本地博客主题配置' })
async config(@Res() res) {
const config = await this.blogThemeConfigService.publicConfig();
return res.send(vbenSuccess(config));
}
@Post('save')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '保存本地博客主题配置' })
async save(@Res() res, @Body() body: BlogThemeConfigBodyDto) {
const result = await this.blogThemeConfigService.save(body);
return res.send(vbenSuccess(result));
}
@Post('import-wordpress')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '从 WordPress 导入主题配置到本地博客' })
async importWordpress(@Res() res) {
const result = await this.blogThemeConfigService.importFromWordpress();
return res.send(vbenSuccess(result));
}
}

View File

@ -0,0 +1,14 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
export class BlogThemeConfigBodyDto {
@ApiPropertyOptional({
description: 'Argon 主题配置 JSON',
})
config?: WordpressArgonThemeConfig;
@ApiPropertyOptional({
description: '配置来源',
})
source?: string;
}

View File

@ -0,0 +1,44 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { FormatDateTime } from '@/common';
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
@Entity('blog_theme_config')
export class BlogThemeConfig {
@ApiProperty()
@PrimaryColumn({
length: 64,
type: 'varchar',
})
id: string;
@ApiProperty()
@Column({
type: 'simple-json',
})
config: WordpressArgonThemeConfig;
@ApiPropertyOptional()
@Column({
default: 'local',
})
source: string;
@CreateDateColumn({
name: 'create_time',
})
@FormatDateTime()
createTime: Date;
@UpdateDateColumn({
name: 'update_time',
})
@FormatDateTime()
updateTime: Date;
}

View File

@ -0,0 +1,146 @@
import { HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import { WordpressService } from '@/wordpress/wordpress.service';
import type { WordpressArgonThemeConfig } from '@/wordpress/wordpress.types';
import { BlogThemeConfigBodyDto } from './blog-theme-config.dto';
import { BlogThemeConfig } from './blog-theme-config.entity';
const DEFAULT_THEME_ID = 'argon';
@Injectable()
export class BlogThemeConfigService {
constructor(
@InjectRepository(BlogThemeConfig)
private readonly themeRepository: Repository<BlogThemeConfig>,
private readonly wordpressService: WordpressService,
private readonly toolsService: ToolsService,
) {}
async publicConfig() {
const localConfig = await this.themeRepository.findOne({
where: {
id: DEFAULT_THEME_ID,
},
});
return localConfig?.config || this.getDefaultConfig();
}
async save(body: BlogThemeConfigBodyDto) {
if (!body.config) {
throwVbenError('请提供主题配置', HttpStatus.BAD_REQUEST);
}
return this.upsertConfig(
body.config,
this.toolsService.toTrimmedString(body.source) || 'local',
);
}
async importFromWordpress() {
const config = await this.wordpressService.themeConfig();
return this.upsertConfig(config, 'wordpress');
}
private async upsertConfig(
config: WordpressArgonThemeConfig,
source: string,
) {
const existing = await this.themeRepository.findOne({
where: {
id: DEFAULT_THEME_ID,
},
});
const saved = await this.themeRepository.save(
existing
? Object.assign(existing, { config, source })
: this.themeRepository.create({
config,
id: DEFAULT_THEME_ID,
source,
}),
);
return saved.config;
}
private getDefaultConfig(): WordpressArgonThemeConfig {
return {
argonConfig: {
codeHighlight: {
breakLine: false,
enable: true,
hideLinenumber: false,
transparentLinenumber: false,
},
dateFormat: 'YMD',
disablePjax: true,
foldLongComments: false,
foldLongShuoshuo: false,
headroom: 'false',
language: 'zh_CN',
lazyload: {
effect: 'fadeIn',
threshold: 800,
},
pangu: 'article',
pjaxAnimationDuration: 600,
waterflowColumns: '1',
wpPath: '/',
zoomify: false,
},
backgroundDarkBrightness: 0.65,
backgroundDarkImage: '/argon/theme/img-2-1200x1000.jpg',
backgroundDarkOpacity: 1,
backgroundImage: '/argon/theme/img-2-1200x1000.jpg',
backgroundOpacity: 1,
bodyClass: ['home', 'blog', 'wp-theme-argon'],
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
headerMenu: [
{
href: '/',
label: '首页',
},
{
href: '/archives',
label: '归档',
},
],
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
site: {
authorAvatar: '/argon/theme/profile.jpg',
authorName: 'KwiTsukasa',
description: '',
home: '',
title: 'KwiTsukasa的小站',
url: '',
},
sidebarMenu: [
{
href: '/',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: '/admin',
icon: 'fa-user',
label: '管理',
},
],
themeCardRadius: 4,
themeColor: '#c3a1ed',
themeColorRgb: '195,161,237',
themeVersion: '1.3.5',
};
}
}

31
src/blog/blog.module.ts Normal file
View File

@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { CommonModule } from '@/common';
import { WordpressModule } from '@/wordpress/wordpress.module';
import { BlogArticleController } from './blog-article.controller';
import { BlogArticle } from './blog-article.entity';
import { BlogArticleService } from './blog-article.service';
import { BlogThemeConfigController } from './blog-theme-config.controller';
import { BlogThemeConfig } from './blog-theme-config.entity';
import { BlogThemeConfigService } from './blog-theme-config.service';
import { BlogTermController } from './blog-term.controller';
import { BlogTerm } from './blog-term.entity';
import { BlogTermService } from './blog-term.service';
@Module({
imports: [
AdminAuthGuardModule,
CommonModule,
WordpressModule,
TypeOrmModule.forFeature([BlogArticle, BlogTerm, BlogThemeConfig]),
],
controllers: [
BlogArticleController,
BlogTermController,
BlogThemeConfigController,
],
providers: [BlogArticleService, BlogTermService, BlogThemeConfigService],
exports: [BlogArticleService, BlogTermService, BlogThemeConfigService],
})
export class BlogModule {}

View File

@ -1,10 +1,11 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { LokiLogPublisherService } from './logger/loki-log-publisher.service'; import { LokiLogPublisherService } from './logger/loki-log-publisher.service';
import { MarkdownService } from './services/markdown.service';
import { ToolsService } from './services/tool.service'; import { ToolsService } from './services/tool.service';
@Global() @Global()
@Module({ @Module({
exports: [LokiLogPublisherService, ToolsService], exports: [LokiLogPublisherService, MarkdownService, ToolsService],
providers: [LokiLogPublisherService, ToolsService], providers: [LokiLogPublisherService, MarkdownService, ToolsService],
}) })
export class CommonModule {} export class CommonModule {}

View File

@ -9,6 +9,7 @@ export * from './interceptors/save-body.interceptor';
export * from './logger/loki-log-publisher.service'; export * from './logger/loki-log-publisher.service';
export * from './logger/pino-logger.config'; export * from './logger/pino-logger.config';
export * from './response/vben-response'; export * from './response/vben-response';
export * from './services/markdown.service';
export * from './services/tool.service'; export * from './services/tool.service';
export * from './snowflake/snowflake-id'; export * from './snowflake/snowflake-id';
export * from './snowflake/snowflake-id.subscriber'; export * from './snowflake/snowflake-id.subscriber';

View File

@ -0,0 +1,234 @@
import { Injectable } from '@nestjs/common';
type MarkdownProcessor = {
process: (value: string) => Promise<unknown>;
};
type UnifiedModule = typeof import('unified');
type RemarkParseModule = typeof import('remark-parse');
type RemarkGfmModule = typeof import('remark-gfm');
type RemarkRehypeModule = typeof import('remark-rehype');
type RehypeRawModule = typeof import('rehype-raw');
type RehypeParseModule = typeof import('rehype-parse');
type RehypeRemarkModule = typeof import('rehype-remark');
type RehypeSanitizeModule = typeof import('rehype-sanitize');
type RehypeStringifyModule = typeof import('rehype-stringify');
type RemarkStringifyModule = typeof import('remark-stringify');
const importEsm = new Function(
'specifier',
'return import(specifier)',
) as <T>(specifier: string) => Promise<T>;
const MARKDOWN_SOURCE_PATTERN =
/<!--\s*kt-markdown-source:([A-Za-z0-9+/=]+)\s*-->/;
const CONTENT_CLASS_PATTERN =
/^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/;
@Injectable()
export class MarkdownService {
private processorPromise?: Promise<MarkdownProcessor>;
private htmlToMarkdownProcessorPromise?: Promise<MarkdownProcessor>;
private sanitizeHtmlProcessorPromise?: Promise<MarkdownProcessor>;
async renderToHtml(markdown?: string | null) {
const processor = await this.getProcessor();
const file = await processor.process(markdown || '');
return `${file}`;
}
async renderHtmlToMarkdown(html?: string | null) {
const processor = await this.getHtmlToMarkdownProcessor();
const file = await processor.process(this.stripSourceMarker(html || ''));
return `${file}`.trim();
}
async sanitizeHtml(html?: string | null) {
const processor = await this.getSanitizeHtmlProcessor();
const file = await processor.process(this.stripSourceMarker(html || ''));
return `${file}`.trim();
}
embedSourceHtml(html: string, markdown?: string | null) {
const source = markdown || '';
if (!source) return this.stripSourceMarker(html);
return `${this.stripSourceMarker(html)}\n<!-- kt-markdown-source:${Buffer.from(
source,
'utf8',
).toString('base64')} -->`;
}
extractSource(value?: unknown) {
const text = `${value ?? ''}`;
const source = MARKDOWN_SOURCE_PATTERN.exec(text)?.[1];
if (!source) return '';
try {
return Buffer.from(source, 'base64').toString('utf8');
} catch {
return '';
}
}
stripSourceMarker(value?: unknown) {
return `${value ?? ''}`.replace(MARKDOWN_SOURCE_PATTERN, '').trim();
}
private getProcessor() {
if (!this.processorPromise) {
this.processorPromise = this.createProcessor();
}
return this.processorPromise;
}
private getHtmlToMarkdownProcessor() {
if (!this.htmlToMarkdownProcessorPromise) {
this.htmlToMarkdownProcessorPromise =
this.createHtmlToMarkdownProcessor();
}
return this.htmlToMarkdownProcessorPromise;
}
private getSanitizeHtmlProcessor() {
if (!this.sanitizeHtmlProcessorPromise) {
this.sanitizeHtmlProcessorPromise = this.createSanitizeHtmlProcessor();
}
return this.sanitizeHtmlProcessorPromise;
}
private async createProcessor(): Promise<MarkdownProcessor> {
const [
{ unified },
{ default: remarkParse },
{ default: remarkGfm },
{ default: remarkRehype },
{ default: rehypeRaw },
{ default: rehypeSanitize, defaultSchema },
{ default: rehypeStringify },
] = await Promise.all([
importEsm<UnifiedModule>('unified'),
importEsm<RemarkParseModule>('remark-parse'),
importEsm<RemarkGfmModule>('remark-gfm'),
importEsm<RemarkRehypeModule>('remark-rehype'),
importEsm<RehypeRawModule>('rehype-raw'),
importEsm<RehypeSanitizeModule>('rehype-sanitize'),
importEsm<RehypeStringifyModule>('rehype-stringify'),
]);
const schema = this.createSanitizeSchema(defaultSchema);
return unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, {
allowDangerousHtml: true,
clobberPrefix: 'kt-md-',
footnoteBackContent: '返回',
footnoteLabel: '脚注',
})
.use(rehypeRaw)
.use(rehypeSanitize, schema)
.use(rehypeStringify) as MarkdownProcessor;
}
private async createHtmlToMarkdownProcessor(): Promise<MarkdownProcessor> {
const [
{ unified },
{ default: rehypeParse },
{ default: rehypeRemark },
{ default: remarkGfm },
{ default: remarkStringify },
] = await Promise.all([
importEsm<UnifiedModule>('unified'),
importEsm<RehypeParseModule>('rehype-parse'),
importEsm<RehypeRemarkModule>('rehype-remark'),
importEsm<RemarkGfmModule>('remark-gfm'),
importEsm<RemarkStringifyModule>('remark-stringify'),
]);
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeRemark)
.use(remarkGfm)
.use(remarkStringify, {
bullet: '-',
fences: true,
listItemIndent: 'one',
}) as MarkdownProcessor;
}
private async createSanitizeHtmlProcessor(): Promise<MarkdownProcessor> {
const [
{ unified },
{ default: rehypeParse },
{ default: rehypeSanitize, defaultSchema },
{ default: rehypeStringify },
] = await Promise.all([
importEsm<UnifiedModule>('unified'),
importEsm<RehypeParseModule>('rehype-parse'),
importEsm<RehypeSanitizeModule>('rehype-sanitize'),
importEsm<RehypeStringifyModule>('rehype-stringify'),
]);
return unified()
.use(rehypeParse, { fragment: true })
.use(rehypeSanitize, this.createSanitizeSchema(defaultSchema))
.use(rehypeStringify) as MarkdownProcessor;
}
private createSanitizeSchema(defaultSchema: unknown) {
const schema = defaultSchema as Record<string, any>;
const attributes = schema.attributes || {};
const classNameAttribute = ['className', CONTENT_CLASS_PATTERN];
return {
...schema,
tagNames: [
...new Set([
...(schema.tagNames || []),
'figcaption',
'figure',
]),
],
attributes: {
...attributes,
a: [...(attributes.a || []), 'target', 'rel', classNameAttribute],
blockquote: [...(attributes.blockquote || []), classNameAttribute],
code: [...(attributes.code || []), classNameAttribute],
div: [...(attributes.div || []), classNameAttribute],
figcaption: [...(attributes.figcaption || []), classNameAttribute],
figure: [...(attributes.figure || []), classNameAttribute],
h1: [...(attributes.h1 || []), classNameAttribute],
h2: [...(attributes.h2 || []), classNameAttribute],
h3: [...(attributes.h3 || []), classNameAttribute],
h4: [...(attributes.h4 || []), classNameAttribute],
h5: [...(attributes.h5 || []), classNameAttribute],
h6: [...(attributes.h6 || []), classNameAttribute],
img: [
...(attributes.img || []),
'loading',
classNameAttribute,
],
li: [...(attributes.li || []), classNameAttribute],
ol: [...(attributes.ol || []), classNameAttribute],
p: [...(attributes.p || []), classNameAttribute],
pre: [...(attributes.pre || []), classNameAttribute],
span: [...(attributes.span || []), classNameAttribute],
table: [...(attributes.table || []), classNameAttribute],
tbody: [...(attributes.tbody || []), classNameAttribute],
td: [...(attributes.td || []), classNameAttribute],
th: [...(attributes.th || []), classNameAttribute],
thead: [...(attributes.thead || []), classNameAttribute],
tr: [...(attributes.tr || []), classNameAttribute],
ul: [...(attributes.ul || []), classNameAttribute],
},
};
}
}

View File

@ -132,6 +132,17 @@ export class ToolsService {
return value === undefined || value === null ? '' : `${value}`; return value === undefined || value === null ? '' : `${value}`;
} }
normalizeSlugText(value: unknown) {
const text = this.toTrimmedString(value);
if (!text) return '';
try {
return decodeURIComponent(text).toLowerCase().replace(/\s+/g, '-');
} catch {
return text.toLowerCase().replace(/\s+/g, '-');
}
}
toPositiveNumber( toPositiveNumber(
value: number | string | undefined | null, value: number | string | undefined | null,
fallback: number, fallback: number,

View File

@ -13,7 +13,7 @@ import {
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common'; import { Public, vbenSuccess } from '@/common';
import { import {
WordpressArticleBodyDto, WordpressArticleBodyDto,
WordpressArticleListQueryDto, WordpressArticleListQueryDto,
@ -37,6 +37,33 @@ import { WordpressService } from './wordpress.service';
export class WordpressArticleController { export class WordpressArticleController {
constructor(private readonly wordpressService: WordpressService) {} constructor(private readonly wordpressService: WordpressService) {}
@Get('public/list')
@Public()
@ApiOperation({ summary: '获取公开 WordPress 文章分页列表' })
async publicList(@Res() res, @Query() query: WordpressArticleListQueryDto) {
const list = await this.wordpressService.publicArticleList(query);
return res.send(vbenSuccess(list));
}
@Get('public/detail')
@Public()
@ApiOperation({ summary: '获取公开 WordPress 文章详情' })
@ApiQuery({ name: 'slug', required: false, type: String })
@ApiQuery({ name: 'id', required: false, type: Number })
async publicDetail(
@Res() res,
@Query('slug') slug?: string,
@Query('id') id?: string,
) {
const detail = await this.wordpressService.publicArticleDetail({
id,
slug,
});
return res.send(vbenSuccess(detail));
}
@Get('list') @Get('list')
@ApiOperation({ summary: '获取 WordPress 文章分页列表' }) @ApiOperation({ summary: '获取 WordPress 文章分页列表' })
async list( async list(

View File

@ -0,0 +1,21 @@
import { Controller, Get, Res, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { Public, vbenSuccess } from '@/common';
import { WordpressService } from './wordpress.service';
@ApiTags('WordPress - 主题')
@Controller('wordpress/theme')
@UseGuards(JwtAuthGuard)
export class WordpressThemeController {
constructor(private readonly wordpressService: WordpressService) {}
@Get('config')
@Public()
@ApiOperation({ summary: '获取 WordPress Argon 主题配置' })
async config(@Res() res) {
const config = await this.wordpressService.themeConfig();
return res.send(vbenSuccess(config));
}
}

View File

@ -77,6 +77,12 @@ export class WordpressArticleBodyDto {
}) })
content?: string; content?: string;
@ApiPropertyOptional({
enum: ['html', 'markdown'],
description: '文章内容格式markdown 会在后端转换为安全 HTML',
})
contentFormat?: 'html' | 'markdown';
@ApiPropertyOptional({ @ApiPropertyOptional({
description: '文章摘要', description: '文章摘要',
}) })

View File

@ -5,6 +5,7 @@ import { WordpressAuthController } from './wordpress-auth.controller';
import { WordpressCategoryController } from './wordpress-category.controller'; import { WordpressCategoryController } from './wordpress-category.controller';
import { WordpressService } from './wordpress.service'; import { WordpressService } from './wordpress.service';
import { WordpressTagController } from './wordpress-tag.controller'; import { WordpressTagController } from './wordpress-tag.controller';
import { WordpressThemeController } from './wordpress-theme.controller';
@Module({ @Module({
imports: [AdminAuthGuardModule], imports: [AdminAuthGuardModule],
@ -13,6 +14,7 @@ import { WordpressTagController } from './wordpress-tag.controller';
WordpressArticleController, WordpressArticleController,
WordpressTagController, WordpressTagController,
WordpressCategoryController, WordpressCategoryController,
WordpressThemeController,
], ],
providers: [WordpressService], providers: [WordpressService],
exports: [WordpressService], exports: [WordpressService],

View File

@ -1,7 +1,7 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import type { Request, Response as ExpressResponse } from 'express'; import type { Request, Response as ExpressResponse } from 'express';
import { throwVbenError, ToolsService } from '@/common'; import { MarkdownService, throwVbenError, ToolsService } from '@/common';
import type { import type {
WordpressArticleBodyDto, WordpressArticleBodyDto,
WordpressArticleListQueryDto, WordpressArticleListQueryDto,
@ -17,6 +17,8 @@ import type {
WordpressPagedQueryDto, WordpressPagedQueryDto,
WordpressRequestOptions, WordpressRequestOptions,
WordpressResponse, WordpressResponse,
WordpressArgonThemeConfig,
WordpressArgonMenuItem,
} from './wordpress.types'; } from './wordpress.types';
const WORDPRESS_COOKIE_PREFIXES = [ const WORDPRESS_COOKIE_PREFIXES = [
@ -34,6 +36,7 @@ export class WordpressService {
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly markdownService: MarkdownService,
private readonly toolsService: ToolsService, private readonly toolsService: ToolsService,
) {} ) {}
@ -185,7 +188,7 @@ export class WordpressService {
}); });
return { return {
list: response.data, list: response.data.map((item) => this.normalizeArticleResponse(item)),
total: response.total || 0, total: response.total || 0,
}; };
} }
@ -198,17 +201,17 @@ export class WordpressService {
}, },
}); });
return response.data; return this.normalizeArticleResponse(response.data);
} }
async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) { async articleSave(body: WordpressArticleBodyDto, auth: WordpressAuthContext) {
const response = await this.request('/wp-json/wp/v2/posts', { const response = await this.request('/wp-json/wp/v2/posts', {
auth, auth,
body: this.getArticleBody(body), body: await this.getArticleBody(body),
method: 'POST', method: 'POST',
}); });
return response.data; return this.normalizeArticleResponse(response.data);
} }
async articleUpdate( async articleUpdate(
@ -217,11 +220,11 @@ export class WordpressService {
) { ) {
const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, { const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
auth, auth,
body: this.getArticleBody(body), body: await this.getArticleBody(body),
method: 'POST', method: 'POST',
}); });
return response.data; return this.normalizeArticleResponse(response.data);
} }
async articleRemove( async articleRemove(
@ -240,6 +243,64 @@ export class WordpressService {
return response.data; return response.data;
} }
async publicArticleList(query: WordpressArticleListQueryDto) {
const response = await this.publicRequest<any[]>('/wp-json/wp/v2/posts', {
query: {
...this.getPageQuery(query),
_embed: 'author,wp:featuredmedia,wp:term',
author: query.author,
categories: this.normalizeIdQuery(query.categories),
context: 'view',
search: query.search,
status: 'publish',
tags: this.normalizeIdQuery(query.tags),
},
});
return {
list: response.data.map((item) => this.normalizeArticleResponse(item)),
total: response.total || 0,
};
}
async publicArticleDetail(query: { id?: string; slug?: string }) {
if (query.id) {
const response = await this.publicRequest(
`/wp-json/wp/v2/posts/${query.id}`,
{
query: {
_embed: 'author,wp:featuredmedia,wp:term',
context: 'view',
},
},
);
return this.normalizeArticleResponse(response.data);
}
const slug = `${query.slug || ''}`.trim();
if (!slug) {
throwVbenError('文章别名不能为空', HttpStatus.BAD_REQUEST);
}
const response = await this.publicRequest<any[]>('/wp-json/wp/v2/posts', {
query: {
_embed: 'author,wp:featuredmedia,wp:term',
context: 'view',
per_page: 1,
slug,
status: 'publish',
},
});
const [article] = response.data;
if (!article) {
throwVbenError('文章不存在或未发布', HttpStatus.NOT_FOUND);
}
return this.normalizeArticleResponse(article);
}
async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) { async tagList(query: WordpressTermListQueryDto, auth: WordpressAuthContext) {
return this.termList('/wp-json/wp/v2/tags', query, auth); return this.termList('/wp-json/wp/v2/tags', query, auth);
} }
@ -297,6 +358,17 @@ export class WordpressService {
return this.termRemove('/wp-json/wp/v2/categories', id, force, auth); return this.termRemove('/wp-json/wp/v2/categories', id, force, auth);
} }
async themeConfig(): Promise<WordpressArgonThemeConfig> {
const [rootResponse, homeResponse] = await Promise.all([
this.rawRequest('/?rest_route=/'),
this.rawRequest('/'),
]);
const root = await this.parseResponse(rootResponse);
const html = await homeResponse.text();
return this.parseArgonThemeConfig(html, root);
}
private async termList( private async termList(
path: string, path: string,
query: WordpressTermListQueryDto, query: WordpressTermListQueryDto,
@ -385,6 +457,20 @@ export class WordpressService {
): Promise<WordpressResponse<T>> { ): Promise<WordpressResponse<T>> {
this.assertAuthContext(options.auth); this.assertAuthContext(options.auth);
return this.executeJsonRequest(path, options);
}
private async publicRequest<T>(
path: string,
options: Omit<WordpressRequestOptions, 'auth'> = {},
): Promise<WordpressResponse<T>> {
return this.executeJsonRequest(path, options);
}
private async executeJsonRequest<T>(
path: string,
options: WordpressRequestOptions,
): Promise<WordpressResponse<T>> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.getTimeout()); const timer = setTimeout(() => controller.abort(), this.getTimeout());
@ -568,9 +654,9 @@ export class WordpressService {
} }
} }
private assertAuthContext(auth: WordpressAuthContext) { private assertAuthContext(auth?: WordpressAuthContext) {
const hasToken = !!auth.authorization; const hasToken = !!auth?.authorization;
const hasCookieLogin = !!auth.cookie && !!auth.nonce; const hasCookieLogin = !!auth?.cookie && !!auth?.nonce;
if (hasToken || hasCookieLogin) return; if (hasToken || hasCookieLogin) return;
@ -581,7 +667,7 @@ export class WordpressService {
); );
} }
private getHeaders(auth: WordpressAuthContext, hasBody: boolean) { private getHeaders(auth: WordpressAuthContext | undefined, hasBody: boolean) {
const headers: Record<string, string> = { const headers: Record<string, string> = {
Accept: 'application/json', Accept: 'application/json',
}; };
@ -590,15 +676,15 @@ export class WordpressService {
headers['Content-Type'] = 'application/json'; headers['Content-Type'] = 'application/json';
} }
if (auth.authorization) { if (auth?.authorization) {
headers.Authorization = auth.authorization; headers.Authorization = auth.authorization;
} }
if (auth.cookie) { if (auth?.cookie) {
headers.Cookie = auth.cookie; headers.Cookie = auth.cookie;
} }
if (auth.nonce) { if (auth?.nonce) {
headers['X-WP-Nonce'] = auth.nonce; headers['X-WP-Nonce'] = auth.nonce;
} }
@ -703,10 +789,10 @@ export class WordpressService {
}; };
} }
private getArticleBody(body: WordpressArticleBodyDto) { private async getArticleBody(body: WordpressArticleBodyDto) {
return this.toolsService.pickDefined({ return this.toolsService.pickDefined({
categories: this.normalizeIdList(body.categories), categories: this.normalizeIdList(body.categories),
content: body.content, content: await this.getArticleContent(body),
excerpt: body.excerpt, excerpt: body.excerpt,
featured_media: body.featured_media, featured_media: body.featured_media,
slug: body.slug, slug: body.slug,
@ -717,6 +803,96 @@ export class WordpressService {
}); });
} }
private async getArticleContent(body: WordpressArticleBodyDto) {
if (body.content === undefined) return undefined;
if (body.contentFormat !== 'markdown') return body.content;
const html = await this.markdownService.renderToHtml(body.content);
return this.markdownService.embedSourceHtml(html, body.content);
}
private normalizeArticleResponse(article: Record<string, any>) {
const content = article?.content;
const contentValue =
typeof content === 'string'
? content
: content?.raw || content?.rendered || '';
const contentMarkdown = this.markdownService.extractSource(contentValue);
const contentHtml = this.markdownService.stripSourceMarker(contentValue);
const nextArticle: Record<string, any> = {
...article,
authorName: this.getEmbeddedAuthorName(article),
categoriesResolved: this.getEmbeddedTerms(article, 'category'),
contentHtml,
cover: this.getEmbeddedFeaturedMediaUrl(article),
excerptText: this.stripHtml(article?.excerpt?.rendered || article?.excerpt),
tagsResolved: this.getEmbeddedTerms(article, 'post_tag'),
};
if (contentMarkdown) {
nextArticle.contentMarkdown = contentMarkdown;
}
if (typeof content === 'string') {
nextArticle.content = this.markdownService.stripSourceMarker(content);
return nextArticle;
}
nextArticle.content = {
...content,
raw: content.raw
? this.markdownService.stripSourceMarker(content.raw)
: content.raw,
rendered: content.rendered
? this.markdownService.stripSourceMarker(content.rendered)
: content.rendered,
};
return nextArticle;
}
private getEmbeddedTerms(article: Record<string, any>, taxonomy: string) {
const terms = article?._embedded?.['wp:term'];
if (!Array.isArray(terms)) return [];
return terms
.flat()
.filter((item) => item?.taxonomy === taxonomy)
.map((item) =>
this.toolsService.pickDefined({
count: item.count,
id: item.id,
name: this.decodeHtmlEntities(item.name || ''),
slug: item.slug,
}),
);
}
private getEmbeddedAuthorName(article: Record<string, any>) {
const [author] = article?._embedded?.author || [];
return this.decodeHtmlEntities(author?.name || '');
}
private getEmbeddedFeaturedMediaUrl(article: Record<string, any>) {
const [media] = article?._embedded?.['wp:featuredmedia'] || [];
return (
media?.media_details?.sizes?.large?.source_url ||
media?.media_details?.sizes?.full?.source_url ||
media?.source_url ||
''
);
}
private stripHtml(value: unknown) {
return this.decodeHtmlEntities(`${value ?? ''}`)
.replace(/<[^>]*>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
private getTermBody(body: WordpressTermBodyDto) { private getTermBody(body: WordpressTermBodyDto) {
return this.toolsService.pickDefined({ return this.toolsService.pickDefined({
description: body.description, description: body.description,
@ -766,6 +942,395 @@ export class WordpressService {
} }
} }
private parseArgonThemeConfig(
html: string,
root: any,
): WordpressArgonThemeConfig {
const argonConfigBlock = this.getScriptObjectBlock(html, 'argonConfig');
return {
argonConfig: {
codeHighlight: {
breakLine: this.getJsObjectBoolean(
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
'break_line',
false,
),
enable: this.getJsObjectBoolean(
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
'enable',
false,
),
hideLinenumber: this.getJsObjectBoolean(
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
'hide_linenumber',
false,
),
transparentLinenumber: this.getJsObjectBoolean(
this.getJsObjectBlock(argonConfigBlock, 'code_highlight'),
'transparent_linenumber',
false,
),
},
dateFormat: this.getJsObjectString(
argonConfigBlock,
'dateFormat',
'YMD',
),
disablePjax: this.getJsObjectBoolean(
argonConfigBlock,
'disable_pjax',
false,
),
foldLongComments: this.getJsObjectBoolean(
argonConfigBlock,
'fold_long_comments',
false,
),
foldLongShuoshuo: this.getJsObjectBoolean(
argonConfigBlock,
'fold_long_shuoshuo',
false,
),
headroom: this.getJsObjectRawValue(argonConfigBlock, 'headroom') || '',
language: this.getJsObjectString(argonConfigBlock, 'language', ''),
lazyload: {
effect: this.getJsObjectString(
this.getJsObjectBlock(argonConfigBlock, 'lazyload'),
'effect',
'',
),
threshold: this.getJsObjectNumber(
this.getJsObjectBlock(argonConfigBlock, 'lazyload'),
'threshold',
0,
),
},
pangu: this.getJsObjectString(argonConfigBlock, 'pangu', ''),
pjaxAnimationDuration: this.getJsObjectNumber(
argonConfigBlock,
'pjax_animation_durtion',
0,
),
waterflowColumns:
this.getJsObjectRawValue(argonConfigBlock, 'waterflow_columns') || '',
wpPath: this.getJsObjectString(argonConfigBlock, 'wp_path', ''),
zoomify: this.getJsObjectBoolean(argonConfigBlock, 'zoomify', false),
},
backgroundDarkBrightness: this.getArgonBackgroundDarkBrightness(html),
backgroundDarkImage: this.getArgonBackgroundImage(html, '#content:after'),
backgroundDarkOpacity: this.getArgonBackgroundDarkOpacity(html),
backgroundImage: this.getArgonBackgroundImage(html),
backgroundOpacity: this.getArgonBackgroundOpacity(html),
bodyClass: this.getTagClassList(html, 'body'),
darkmodeAutoSwitch: this.getInlineJsString(
html,
'darkmodeAutoSwitch',
),
enableCustomThemeColor:
this.getMetaContent(html, 'argon-enable-custom-theme-color') === 'true',
headerMenu: this.getArgonMenuItems(html, 'navbar_global'),
htmlClass: this.getTagClassList(html, 'html').filter(
(item) => item !== 'no-js',
),
site: {
authorAvatar: this.getArgonAuthorAvatar(html),
authorName: this.getArgonAuthorName(html),
description: root?.description || '',
home: root?.home || '',
title: root?.name || this.getMetaContent(html, 'og:title') || '',
url: root?.url || '',
},
sidebarMenu: this.getArgonMenuItems(html, 'leftbar_part1_menu'),
themeCardRadius: this.toNonNegativeNumber(
this.getMetaContent(html, 'theme-card-radius'),
4,
),
themeColor: this.getMetaContent(html, 'theme-color'),
themeColorRgb: this.getMetaContent(html, 'theme-color-rgb'),
themeVersion: this.getMetaContent(html, 'theme-version'),
};
}
private getArgonBackgroundImage(html: string, selector = '#content:before') {
const block = this.getCssBlock(html, selector);
const rawValue =
/background(?:-image)?\s*:\s*url\(([^)]+)\)/i.exec(block)?.[1] || '';
const value = rawValue.trim().replace(/^['"]|['"]$/g, '');
return this.decodeUriText(this.decodeHtmlEntities(value));
}
private getArgonBackgroundOpacity(html: string) {
const value = this.getCssDeclaration(
this.getCssBlock(html, '#content:before'),
'opacity',
);
return this.toNonNegativeNumber(value, 1);
}
private getArgonBackgroundDarkOpacity(html: string) {
const darkValue = this.getCssDeclaration(
this.getCssBlock(html, 'html.darkmode #content:after'),
'opacity',
);
const baseValue = this.getCssDeclaration(
this.getCssBlock(html, '#content:after'),
'opacity',
);
return this.toNonNegativeNumber(darkValue || baseValue, 1);
}
private getArgonBackgroundDarkBrightness(html: string) {
const filter = this.getCssDeclaration(
this.getCssBlock(html, 'html.darkmode #content:before'),
'filter',
);
const value = /brightness\(([^)]+)\)/i.exec(filter)?.[1] || '';
return this.toNonNegativeNumber(value, 1);
}
private getArgonAuthorAvatar(html: string) {
const style = this.getTagAttributeById(
html,
'leftbar_overview_author_image',
'style',
);
const value =
/background(?:-image)?\s*:\s*url\(([^)]+)\)/i.exec(style)?.[1] || '';
return this.decodeUriText(
this.decodeHtmlEntities(value.trim().replace(/^['"]|['"]$/g, '')),
);
}
private getArgonAuthorName(html: string) {
const pattern =
/<[^>]*id=["']leftbar_overview_author_name["'][^>]*>([\s\S]*?)<\/[^>]+>/i;
return this.stripHtml(pattern.exec(html)?.[1] || '');
}
private getArgonMenuItems(
html: string,
elementId: string,
): WordpressArgonMenuItem[] {
const menuHtml = this.getElementHtmlById(html, elementId);
return Array.from(menuHtml.matchAll(/<a\b([^>]*)>([\s\S]*?)<\/a>/gi))
.map((match) => {
const attrText = match[1] || '';
const href = this.getHtmlAttribute(attrText, 'href');
const label = this.stripHtml(match[2] || '');
const icon = /<i\b[^>]*class=["']([^"']*)["']/i.exec(match[2] || '')?.[1] || '';
if (!href || !label) return null;
const menuItem: WordpressArgonMenuItem = {
href: this.decodeHtmlEntities(href),
label,
};
const iconName = icon
.split(/\s+/)
.find((item) => item.startsWith('fa-'));
if (/^https?:\/\//i.test(href)) {
menuItem.external = true;
}
if (iconName) {
menuItem.icon = iconName;
}
return menuItem;
})
.filter((item): item is WordpressArgonMenuItem => !!item);
}
private getCssBlock(html: string, selector: string) {
const pattern = new RegExp(
`${this.escapeRegex(selector)}\\s*\\{(?<body>[^}]*)\\}`,
'i',
);
return pattern.exec(html)?.groups?.body || '';
}
private getCssDeclaration(block: string, property: string) {
const pattern = new RegExp(
`${this.escapeRegex(property)}\\s*:\\s*([^;]+)`,
'i',
);
return pattern.exec(block)?.[1]?.trim() || '';
}
private getTagAttributeById(html: string, id: string, attribute: string) {
const tagPattern = new RegExp(
`<[^>]*id=["']${this.escapeRegex(id)}["'][^>]*>`,
'i',
);
const tag = tagPattern.exec(html)?.[0] || '';
const attrPattern = new RegExp(
`${this.escapeRegex(attribute)}=["']([^"']*)["']`,
'i',
);
return attrPattern.exec(tag)?.[1] || '';
}
private getElementHtmlById(html: string, id: string) {
const startPattern = new RegExp(
`<(?<tag>[a-z0-9-]+)[^>]*id=["']${this.escapeRegex(id)}["'][^>]*>`,
'i',
);
const startMatch = startPattern.exec(html);
if (!startMatch?.groups?.tag) return '';
const tag = startMatch.groups.tag;
const startIndex = startMatch.index + startMatch[0].length;
const endPattern = new RegExp(`</${this.escapeRegex(tag)}>`, 'i');
const endMatch = endPattern.exec(html.slice(startIndex));
return endMatch
? html.slice(startIndex, startIndex + endMatch.index)
: html.slice(startIndex);
}
private getHtmlAttribute(attrText: string, attribute: string) {
const pattern = new RegExp(
`${this.escapeRegex(attribute)}=["']([^"']*)["']`,
'i',
);
return pattern.exec(attrText)?.[1] || '';
}
private getMetaContent(html: string, name: string) {
const escapedName = this.escapeRegex(name);
const pattern = new RegExp(
`<meta[^>]+(?:name|property)=["']${escapedName}["'][^>]+content=["']([^"']*)["']`,
'i',
);
return this.decodeHtmlEntities(pattern.exec(html)?.[1] || '');
}
private getTagClassList(html: string, tagName: 'body' | 'html') {
const pattern = new RegExp(`<${tagName}[^>]*class=["']([^"']*)["']`, 'i');
const classValue = pattern.exec(html)?.[1] || '';
return classValue
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean);
}
private getScriptObjectBlock(html: string, name: string) {
const pattern = new RegExp(`${this.escapeRegex(name)}\\s*=\\s*\\{`, 'i');
const match = pattern.exec(html);
if (!match) return '';
return this.readBraceBlock(html, match.index + match[0].lastIndexOf('{'));
}
private getJsObjectBlock(block: string, key: string) {
const pattern = new RegExp(`${this.escapeRegex(key)}\\s*:\\s*\\{`, 'i');
const match = pattern.exec(block);
if (!match) return '';
return this.readBraceBlock(block, match.index + match[0].lastIndexOf('{'));
}
private readBraceBlock(value: string, startIndex: number) {
let depth = 0;
for (let index = startIndex; index < value.length; index += 1) {
const char = value[index];
if (char === '{') depth += 1;
if (char === '}') depth -= 1;
if (depth === 0) {
return value.slice(startIndex + 1, index);
}
}
return '';
}
private getInlineJsString(html: string, key: string) {
const pattern = new RegExp(
`${this.escapeRegex(key)}\\s*=\\s*["']([^"']*)["']`,
'i',
);
return pattern.exec(html)?.[1] || '';
}
private getJsObjectString(block: string, key: string, fallback: string) {
const pattern = new RegExp(
`${this.escapeRegex(key)}\\s*:\\s*["']([^"']*)["']`,
'i',
);
return pattern.exec(block)?.[1] || fallback;
}
private getJsObjectNumber(block: string, key: string, fallback: number) {
const pattern = new RegExp(
`${this.escapeRegex(key)}\\s*:\\s*([0-9]+)`,
'i',
);
const value = Number(pattern.exec(block)?.[1]);
return Number.isFinite(value) ? value : fallback;
}
private getJsObjectBoolean(block: string, key: string, fallback: boolean) {
const rawValue = this.getJsObjectRawValue(block, key);
if (rawValue === 'true') return true;
if (rawValue === 'false') return false;
return fallback;
}
private toNonNegativeNumber(value: unknown, fallback: number) {
const nextValue = Number(value);
return Number.isFinite(nextValue) && nextValue >= 0 ? nextValue : fallback;
}
private getJsObjectRawValue(block: string, key: string) {
const pattern = new RegExp(
`${this.escapeRegex(key)}\\s*:\\s*(["'][^"']*["']|true|false|[0-9]+)`,
'i',
);
const value = pattern.exec(block)?.[1] || '';
return value.replace(/^["']|["']$/g, '');
}
private decodeHtmlEntities(value: string) {
return value
.replace(/&quot;/g, '"')
.replace(/&#039;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
private decodeUriText(value: string) {
try {
return decodeURI(value);
} catch {
return value;
}
}
private escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
private getWordpressResponseErrorMessage(data: any, status: number) { private getWordpressResponseErrorMessage(data: any, status: number) {
if (data?.message) return data.message; if (data?.message) return data.message;
if (typeof data === 'string' && data) return data; if (typeof data === 'string' && data) return data;

View File

@ -46,7 +46,7 @@ export type WordpressPagedQueryDto =
| WordpressTermListQueryDto; | WordpressTermListQueryDto;
export type WordpressRequestOptions = { export type WordpressRequestOptions = {
auth: WordpressAuthContext; auth?: WordpressAuthContext;
body?: Record<string, unknown>; body?: Record<string, unknown>;
method?: 'GET' | 'POST' | 'DELETE'; method?: 'GET' | 'POST' | 'DELETE';
query?: Record<string, unknown>; query?: Record<string, unknown>;
@ -56,3 +56,59 @@ export type WordpressResponse<T> = {
data: T; data: T;
total?: number; total?: number;
}; };
export type WordpressArgonMenuItem = {
external?: boolean;
href: string;
icon?: string;
label: string;
};
export type WordpressArgonThemeConfig = {
argonConfig: {
codeHighlight: {
breakLine: boolean;
enable: boolean;
hideLinenumber: boolean;
transparentLinenumber: boolean;
};
dateFormat: string;
disablePjax: boolean;
foldLongComments: boolean;
foldLongShuoshuo: boolean;
headroom: boolean | string;
language: string;
lazyload: {
effect: string;
threshold: number;
};
pangu: string;
pjaxAnimationDuration: number;
waterflowColumns: number | string;
wpPath: string;
zoomify: boolean;
};
backgroundDarkBrightness?: number;
backgroundDarkImage?: string;
backgroundDarkOpacity?: number;
backgroundImage?: string;
backgroundOpacity?: number;
bodyClass: string[];
darkmodeAutoSwitch: string;
enableCustomThemeColor: boolean;
headerMenu?: WordpressArgonMenuItem[];
htmlClass: string[];
site: {
authorAvatar?: string;
authorName?: string;
description: string;
home: string;
title: string;
url: string;
};
sidebarMenu?: WordpressArgonMenuItem[];
themeCardRadius: number;
themeColor: string;
themeColorRgb: string;
themeVersion: string;
};

View File

@ -15,9 +15,16 @@ import { SystemLogController } from '../src/admin/system-log/system-log.controll
import { SystemLogService } from '../src/admin/system-log/system-log.service'; import { SystemLogService } from '../src/admin/system-log/system-log.service';
import { import {
ApiExceptionFilter, ApiExceptionFilter,
IS_PUBLIC_KEY,
SaveBodyInterceptor, SaveBodyInterceptor,
ToolsService, ToolsService,
} from '../src/common'; } from '../src/common';
import { BlogArticleController } from '../src/blog/blog-article.controller';
import { BlogArticleService } from '../src/blog/blog-article.service';
import { BlogThemeConfigController } from '../src/blog/blog-theme-config.controller';
import { BlogThemeConfigService } from '../src/blog/blog-theme-config.service';
import { BlogTermController } from '../src/blog/blog-term.controller';
import { BlogTermService } from '../src/blog/blog-term.service';
import { MinioClientController } from '../src/minio/minio.controller'; import { MinioClientController } from '../src/minio/minio.controller';
import { MinioClientService } from '../src/minio/minio.service'; import { MinioClientService } from '../src/minio/minio.service';
import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller'; import { WordpressArticleController } from '../src/wordpress/wordpress-article.controller';
@ -25,6 +32,7 @@ import { WordpressAuthController } from '../src/wordpress/wordpress-auth.control
import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller'; import { WordpressCategoryController } from '../src/wordpress/wordpress-category.controller';
import { WordpressService } from '../src/wordpress/wordpress.service'; import { WordpressService } from '../src/wordpress/wordpress.service';
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller'; import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
import { WordpressThemeController } from '../src/wordpress/wordpress-theme.controller';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { import {
collectControllerRoutes, collectControllerRoutes,
@ -154,12 +162,56 @@ const wordpressArticle = {
status: 'draft', status: 'draft',
}; };
const blogArticle = {
authorName: 'KwiTsukasa',
categories: ['技术'],
categoriesResolved: [{ name: '技术', slug: 'tech' }],
contentHtml: '<h1>本地文章</h1>',
contentMarkdown: '# 本地文章',
id: '2041800000000000001',
slug: 'local-article',
status: 'draft',
tags: ['Milkdown'],
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
title: '本地文章',
};
const wordpressTerm = { const wordpressTerm = {
id: 1, id: 1,
name: 'WordPress 分类', name: 'WordPress 分类',
slug: 'wordpress-category', slug: 'wordpress-category',
}; };
const blogTerm = {
count: 1,
description: '本地分类描述',
id: '2041800000000000100',
name: '技术',
slug: 'tech',
};
const wordpressThemeConfig = {
bodyClass: ['home', 'blog', 'wp-theme-argon'],
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
site: {
description: '',
home: 'https://blog.kwitsukasa.top',
title: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: 4,
themeColor: '#c3a1ed',
themeColorRgb: '195,161,237',
themeVersion: '1.3.5',
};
const componentServiceMock = { const componentServiceMock = {
all: jest.fn(), all: jest.fn(),
page: jest.fn(), page: jest.fn(),
@ -219,6 +271,34 @@ const minioServiceMock = {
removeObject: jest.fn(), removeObject: jest.fn(),
}; };
const blogArticleServiceMock = {
page: jest.fn(),
detail: jest.fn(),
save: jest.fn(),
update: jest.fn(),
remove: jest.fn(),
categoryOptions: jest.fn(),
tagOptions: jest.fn(),
publicList: jest.fn(),
publicDetail: jest.fn(),
importFromWordpress: jest.fn(),
};
const blogTermServiceMock = {
detail: jest.fn(),
options: jest.fn(),
page: jest.fn(),
remove: jest.fn(),
save: jest.fn(),
update: jest.fn(),
};
const blogThemeConfigServiceMock = {
importFromWordpress: jest.fn(),
publicConfig: jest.fn(),
save: jest.fn(),
};
const wordpressServiceMock = { const wordpressServiceMock = {
getAuthContext: jest.fn(), getAuthContext: jest.fn(),
loginWithConfiguredAdmin: jest.fn(), loginWithConfiguredAdmin: jest.fn(),
@ -230,6 +310,8 @@ const wordpressServiceMock = {
articleSave: jest.fn(), articleSave: jest.fn(),
articleUpdate: jest.fn(), articleUpdate: jest.fn(),
articleRemove: jest.fn(), articleRemove: jest.fn(),
publicArticleList: jest.fn(),
publicArticleDetail: jest.fn(),
tagList: jest.fn(), tagList: jest.fn(),
tagDetail: jest.fn(), tagDetail: jest.fn(),
tagSave: jest.fn(), tagSave: jest.fn(),
@ -240,6 +322,7 @@ const wordpressServiceMock = {
categorySave: jest.fn(), categorySave: jest.fn(),
categoryUpdate: jest.fn(), categoryUpdate: jest.fn(),
categoryRemove: jest.fn(), categoryRemove: jest.fn(),
themeConfig: jest.fn(),
}; };
const controllerClasses = [ const controllerClasses = [
@ -247,11 +330,15 @@ const controllerClasses = [
ComponentController, ComponentController,
DictController, DictController,
SystemLogController, SystemLogController,
BlogArticleController,
BlogTermController,
BlogThemeConfigController,
MinioClientController, MinioClientController,
WordpressAuthController, WordpressAuthController,
WordpressArticleController, WordpressArticleController,
WordpressTagController, WordpressTagController,
WordpressCategoryController, WordpressCategoryController,
WordpressThemeController,
]; ];
const controllerRoutes = collectControllerRoutes(controllerClasses); const controllerRoutes = collectControllerRoutes(controllerClasses);
@ -390,6 +477,522 @@ const routeTestCases: Record<string, RouteTestCase> = {
}); });
}, },
'GET /blog/article/list': async (server) => {
blogArticleServiceMock.page.mockResolvedValue({
list: [blogArticle],
total: 1,
});
const response = await request(server)
.get('/blog/article/list')
.query({ pageNo: 1, pageSize: 10, search: '本地' })
.expect(200);
expect(blogArticleServiceMock.page).toHaveBeenCalledWith({
pageNo: '1',
pageSize: '10',
search: '本地',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [blogArticle],
total: 1,
},
});
},
'GET /blog/article/detail': async (server) => {
blogArticleServiceMock.detail.mockResolvedValue(blogArticle);
const response = await request(server)
.get('/blog/article/detail')
.query({ id: blogArticle.id })
.expect(200);
expect(blogArticleServiceMock.detail).toHaveBeenCalledWith(blogArticle.id);
expect(response.body).toMatchObject({
code: 200,
data: blogArticle,
});
},
'GET /blog/article/public/list': async (server) => {
blogArticleServiceMock.publicList.mockResolvedValue({
list: [blogArticle],
total: 1,
});
const response = await request(server)
.get('/blog/article/public/list')
.query({ pageNo: 1, pageSize: 10 })
.expect(200);
expect(blogArticleServiceMock.publicList).toHaveBeenCalledWith({
pageNo: '1',
pageSize: '10',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [blogArticle],
total: 1,
},
});
},
'GET /blog/article/public/detail': async (server) => {
blogArticleServiceMock.publicDetail.mockResolvedValue(blogArticle);
const response = await request(server)
.get('/blog/article/public/detail')
.query({ slug: blogArticle.slug })
.expect(200);
expect(blogArticleServiceMock.publicDetail).toHaveBeenCalledWith({
id: undefined,
slug: blogArticle.slug,
});
expect(response.body).toMatchObject({
code: 200,
data: blogArticle,
});
},
'POST /blog/article/save': async (server) => {
blogArticleServiceMock.save.mockResolvedValue(blogArticle);
const response = await request(server)
.post('/blog/article/save')
.send({
content: '# 本地文章',
contentFormat: 'markdown',
title: '本地文章',
})
.expect(200);
expect(blogArticleServiceMock.save).toHaveBeenCalledWith({
content: '# 本地文章',
contentFormat: 'markdown',
title: '本地文章',
});
expect(response.body).toMatchObject({
code: 200,
data: blogArticle,
});
},
'POST /blog/article/update': async (server) => {
blogArticleServiceMock.update.mockResolvedValue(blogArticle);
const response = await request(server)
.post('/blog/article/update')
.send({
id: blogArticle.id,
title: '本地文章',
})
.expect(200);
expect(blogArticleServiceMock.update).toHaveBeenCalledWith({
id: blogArticle.id,
title: '本地文章',
});
expect(response.body).toMatchObject({
code: 200,
data: blogArticle,
});
},
'POST /blog/article/remove': async (server) => {
blogArticleServiceMock.remove.mockResolvedValue(true);
const response = await request(server)
.post('/blog/article/remove')
.query({ id: blogArticle.id })
.expect(200);
expect(blogArticleServiceMock.remove).toHaveBeenCalledWith(blogArticle.id);
expect(response.body).toMatchObject({
code: 200,
data: true,
});
},
'GET /blog/article/category-options': async (server) => {
blogArticleServiceMock.categoryOptions.mockResolvedValue({
list: [{ count: 1, id: 'tech', name: '技术', slug: 'tech' }],
total: 1,
});
const response = await request(server)
.get('/blog/article/category-options')
.query({ pageNo: 1, pageSize: 20, search: '技' })
.expect(200);
expect(blogArticleServiceMock.categoryOptions).toHaveBeenCalledWith({
pageNo: '1',
pageSize: '20',
search: '技',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [{ count: 1, id: 'tech', name: '技术', slug: 'tech' }],
total: 1,
},
});
},
'GET /blog/article/tag-options': async (server) => {
blogArticleServiceMock.tagOptions.mockResolvedValue({
list: [{ count: 1, id: 'milkdown', name: 'Milkdown', slug: 'milkdown' }],
total: 1,
});
const response = await request(server)
.get('/blog/article/tag-options')
.query({ pageNo: 1, pageSize: 20, search: 'milk' })
.expect(200);
expect(blogArticleServiceMock.tagOptions).toHaveBeenCalledWith({
pageNo: '1',
pageSize: '20',
search: 'milk',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [
{ count: 1, id: 'milkdown', name: 'Milkdown', slug: 'milkdown' },
],
total: 1,
},
});
},
'POST /blog/article/import-wordpress': async (server) => {
blogArticleServiceMock.importFromWordpress.mockResolvedValue({
created: 1,
items: [
{
action: 'created',
id: blogArticle.id,
slug: blogArticle.slug,
title: blogArticle.title,
},
],
skipped: 0,
total: 1,
updated: 0,
});
const response = await request(server)
.post('/blog/article/import-wordpress')
.send({
overwrite: false,
pageNo: 1,
pageSize: 10,
})
.expect(200);
expect(blogArticleServiceMock.importFromWordpress).toHaveBeenCalledWith({
overwrite: false,
pageNo: 1,
pageSize: 10,
});
expect(response.body).toMatchObject({
code: 200,
data: {
created: 1,
skipped: 0,
total: 1,
updated: 0,
},
});
},
'GET /blog/category/list': async (server) => {
blogTermServiceMock.page.mockResolvedValue({
list: [blogTerm],
total: 1,
});
const response = await request(server)
.get('/blog/category/list')
.query({ pageNo: 1, pageSize: 10, search: '技' })
.expect(200);
expect(blogTermServiceMock.page).toHaveBeenCalledWith('category', {
pageNo: '1',
pageSize: '10',
search: '技',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [blogTerm],
total: 1,
},
});
},
'GET /blog/category/detail': async (server) => {
blogTermServiceMock.detail.mockResolvedValue(blogTerm);
const response = await request(server)
.get('/blog/category/detail')
.query({ id: blogTerm.id })
.expect(200);
expect(blogTermServiceMock.detail).toHaveBeenCalledWith(
'category',
blogTerm.id,
);
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/category/save': async (server) => {
blogTermServiceMock.save.mockResolvedValue(blogTerm);
const response = await request(server)
.post('/blog/category/save')
.send({
description: blogTerm.description,
name: blogTerm.name,
slug: blogTerm.slug,
})
.expect(200);
expect(blogTermServiceMock.save).toHaveBeenCalledWith('category', {
description: blogTerm.description,
name: blogTerm.name,
slug: blogTerm.slug,
});
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/category/update': async (server) => {
blogTermServiceMock.update.mockResolvedValue(blogTerm);
const response = await request(server)
.post('/blog/category/update')
.send({
id: blogTerm.id,
name: blogTerm.name,
})
.expect(200);
expect(blogTermServiceMock.update).toHaveBeenCalledWith('category', {
id: blogTerm.id,
name: blogTerm.name,
});
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/category/remove': async (server) => {
blogTermServiceMock.remove.mockResolvedValue(true);
const response = await request(server)
.post('/blog/category/remove')
.query({ id: blogTerm.id })
.expect(200);
expect(blogTermServiceMock.remove).toHaveBeenCalledWith(
'category',
blogTerm.id,
);
expect(response.body).toMatchObject({
code: 200,
data: true,
});
},
'GET /blog/tag/list': async (server) => {
blogTermServiceMock.page.mockResolvedValue({
list: [blogTerm],
total: 1,
});
const response = await request(server)
.get('/blog/tag/list')
.query({ pageNo: 1, pageSize: 10, search: '技' })
.expect(200);
expect(blogTermServiceMock.page).toHaveBeenCalledWith('tag', {
pageNo: '1',
pageSize: '10',
search: '技',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [blogTerm],
total: 1,
},
});
},
'GET /blog/tag/detail': async (server) => {
blogTermServiceMock.detail.mockResolvedValue(blogTerm);
const response = await request(server)
.get('/blog/tag/detail')
.query({ id: blogTerm.id })
.expect(200);
expect(blogTermServiceMock.detail).toHaveBeenCalledWith('tag', blogTerm.id);
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/tag/save': async (server) => {
blogTermServiceMock.save.mockResolvedValue(blogTerm);
const response = await request(server)
.post('/blog/tag/save')
.send({
name: blogTerm.name,
slug: blogTerm.slug,
})
.expect(200);
expect(blogTermServiceMock.save).toHaveBeenCalledWith('tag', {
name: blogTerm.name,
slug: blogTerm.slug,
});
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/tag/update': async (server) => {
blogTermServiceMock.update.mockResolvedValue(blogTerm);
const response = await request(server)
.post('/blog/tag/update')
.send({
id: blogTerm.id,
name: blogTerm.name,
})
.expect(200);
expect(blogTermServiceMock.update).toHaveBeenCalledWith('tag', {
id: blogTerm.id,
name: blogTerm.name,
});
expect(response.body).toMatchObject({
code: 200,
data: blogTerm,
});
},
'POST /blog/tag/remove': async (server) => {
blogTermServiceMock.remove.mockResolvedValue(true);
const response = await request(server)
.post('/blog/tag/remove')
.query({ id: blogTerm.id })
.expect(200);
expect(blogTermServiceMock.remove).toHaveBeenCalledWith('tag', blogTerm.id);
expect(response.body).toMatchObject({
code: 200,
data: true,
});
},
'GET /blog/term/options': async (server) => {
blogTermServiceMock.options.mockResolvedValue({
list: [blogTerm],
total: 1,
});
const response = await request(server)
.get('/blog/term/options')
.query({ kind: 'category', pageNo: 1, pageSize: 10 })
.expect(200);
expect(blogTermServiceMock.options).toHaveBeenCalledWith('category', {
kind: 'category',
pageNo: '1',
pageSize: '10',
});
expect(response.body).toMatchObject({
code: 200,
data: {
list: [blogTerm],
total: 1,
},
});
},
'GET /blog/theme/config': async (server) => {
blogThemeConfigServiceMock.publicConfig.mockResolvedValue(
wordpressThemeConfig,
);
const response = await request(server)
.get('/blog/theme/config')
.expect(200);
expect(blogThemeConfigServiceMock.publicConfig).toHaveBeenCalledWith();
expect(response.body).toMatchObject({
code: 200,
data: wordpressThemeConfig,
});
},
'POST /blog/theme/save': async (server) => {
blogThemeConfigServiceMock.save.mockResolvedValue(wordpressThemeConfig);
const response = await request(server)
.post('/blog/theme/save')
.send({
config: wordpressThemeConfig,
source: 'local-admin',
})
.expect(200);
expect(blogThemeConfigServiceMock.save).toHaveBeenCalledWith({
config: wordpressThemeConfig,
source: 'local-admin',
});
expect(response.body).toMatchObject({
code: 200,
data: wordpressThemeConfig,
});
},
'POST /blog/theme/import-wordpress': async (server) => {
blogThemeConfigServiceMock.importFromWordpress.mockResolvedValue(
wordpressThemeConfig,
);
const response = await request(server)
.post('/blog/theme/import-wordpress')
.expect(200);
expect(
blogThemeConfigServiceMock.importFromWordpress,
).toHaveBeenCalledWith();
expect(response.body).toMatchObject({
code: 200,
data: wordpressThemeConfig,
});
},
'GET /dict/getDictByKey': async (server) => { 'GET /dict/getDictByKey': async (server) => {
dictServiceMock.getDictByKey.mockResolvedValue(dictOptions); dictServiceMock.getDictByKey.mockResolvedValue(dictOptions);
@ -997,6 +1600,55 @@ const routeTestCases: Record<string, RouteTestCase> = {
}); });
}, },
'GET /wordpress/article/public/list': async (server) => {
wordpressServiceMock.publicArticleList.mockResolvedValue({
list: [wordpressArticle],
total: 1,
});
const response = await request(server)
.get('/wordpress/article/public/list')
.query({
pageNo: 1,
pageSize: 10,
search: '文章',
})
.expect(200);
expect(wordpressServiceMock.publicArticleList).toHaveBeenCalledWith({
pageNo: '1',
pageSize: '10',
search: '文章',
});
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
expect(response.body).toMatchObject({
code: 200,
data: {
list: [wordpressArticle],
total: 1,
},
});
},
'GET /wordpress/article/public/detail': async (server) => {
wordpressServiceMock.publicArticleDetail.mockResolvedValue(wordpressArticle);
const response = await request(server)
.get('/wordpress/article/public/detail')
.query({ slug: 'wordpress-article' })
.expect(200);
expect(wordpressServiceMock.publicArticleDetail).toHaveBeenCalledWith({
id: undefined,
slug: 'wordpress-article',
});
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
expect(response.body).toMatchObject({
code: 200,
data: wordpressArticle,
});
},
'POST /wordpress/article/save': async (server) => { 'POST /wordpress/article/save': async (server) => {
wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle); wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle);
@ -1303,6 +1955,21 @@ const routeTestCases: Record<string, RouteTestCase> = {
data: true, data: true,
}); });
}, },
'GET /wordpress/theme/config': async (server) => {
wordpressServiceMock.themeConfig.mockResolvedValue(wordpressThemeConfig);
const response = await request(server)
.get('/wordpress/theme/config')
.expect(200);
expect(wordpressServiceMock.themeConfig).toHaveBeenCalledWith();
expect(wordpressServiceMock.getAuthContext).not.toHaveBeenCalled();
expect(response.body).toMatchObject({
code: 200,
data: wordpressThemeConfig,
});
},
}; };
describe('KT Template Online API (e2e)', () => { describe('KT Template Online API (e2e)', () => {
@ -1332,6 +1999,18 @@ describe('KT Template Online API (e2e)', () => {
provide: SystemLogService, provide: SystemLogService,
useValue: systemLogServiceMock, useValue: systemLogServiceMock,
}, },
{
provide: BlogArticleService,
useValue: blogArticleServiceMock,
},
{
provide: BlogTermService,
useValue: blogTermServiceMock,
},
{
provide: BlogThemeConfigService,
useValue: blogThemeConfigServiceMock,
},
{ {
provide: MinioClientService, provide: MinioClientService,
useValue: minioServiceMock, useValue: minioServiceMock,
@ -1378,6 +2057,27 @@ describe('KT Template Online API (e2e)', () => {
); );
}); });
it('keeps Blog Web runtime endpoints public for WordPress replacement', () => {
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,
BlogArticleController.prototype.publicList,
),
).toBe(true);
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,
BlogArticleController.prototype.publicDetail,
),
).toBe(true);
expect(
Reflect.getMetadata(
IS_PUBLIC_KEY,
BlogThemeConfigController.prototype.config,
),
).toBe(true);
});
describe('generated route smoke tests', () => { describe('generated route smoke tests', () => {
controllerRoutes.forEach((route) => { controllerRoutes.forEach((route) => {
const key = routeKey(route); const key = routeKey(route);

View File

@ -0,0 +1,323 @@
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test } from '@nestjs/testing';
import { MarkdownService, ToolsService } from '../../src/common';
import { BlogArticle } from '../../src/blog/blog-article.entity';
import { BlogArticleService } from '../../src/blog/blog-article.service';
import { BlogTermService } from '../../src/blog/blog-term.service';
import { WordpressService } from '../../src/wordpress/wordpress.service';
describe('BlogArticleService', () => {
let service: BlogArticleService;
let markdownService: MarkdownService;
let wordpressService: {
publicArticleDetail: jest.Mock;
publicArticleList: jest.Mock;
};
let blogTermService: {
options: jest.Mock;
syncTerms: jest.Mock;
};
let repository: {
create: jest.Mock;
createQueryBuilder: jest.Mock;
find: jest.Mock;
findOne: jest.Mock;
save: jest.Mock;
update: jest.Mock;
};
beforeEach(async () => {
repository = {
create: jest.fn((payload) => ({ id: '1', ...payload })),
createQueryBuilder: jest.fn(),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(async (article) => article),
update: jest.fn(),
};
markdownService = new MarkdownService();
jest
.spyOn(markdownService, 'renderToHtml')
.mockResolvedValue('<h1>标题</h1>\n<p>正文</p>');
jest
.spyOn(markdownService, 'renderHtmlToMarkdown')
.mockResolvedValue('# 导入标题\n\n导入正文');
jest
.spyOn(markdownService, 'sanitizeHtml')
.mockImplementation(async (html) =>
`${html ?? ''}`.replace(/<script[\s\S]*?<\/script>/g, ''),
);
wordpressService = {
publicArticleDetail: jest.fn(),
publicArticleList: jest.fn(),
};
blogTermService = {
options: jest.fn(),
syncTerms: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [
BlogArticleService,
ToolsService,
{
provide: MarkdownService,
useValue: markdownService,
},
{
provide: getRepositoryToken(BlogArticle),
useValue: repository,
},
{
provide: WordpressService,
useValue: wordpressService,
},
{
provide: BlogTermService,
useValue: blogTermService,
},
],
}).compile();
service = moduleRef.get(BlogArticleService);
});
it('renders markdown and normalizes local article fields before saving', async () => {
const result = await service.save({
categories: ['技术'],
content: '# 标题\n\n正文',
contentFormat: 'markdown',
status: 'publish',
tags: ['Milkdown'],
title: '测试 文章',
});
expect(markdownService.renderToHtml).toHaveBeenCalledWith('# 标题\n\n正文');
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
categoryItems: [{ name: '技术', slug: '技术' }],
contentHtml: '<h1>标题</h1>\n<p>正文</p>',
contentMarkdown: '# 标题\n\n正文',
slug: '测试-文章',
status: 'publish',
tagItems: [{ name: 'Milkdown', slug: 'milkdown' }],
}),
);
expect(blogTermService.syncTerms).toHaveBeenCalledWith('category', [
{ name: '技术', slug: '技术' },
]);
expect(blogTermService.syncTerms).toHaveBeenCalledWith('tag', [
{ name: 'Milkdown', slug: 'milkdown' },
]);
expect(repository.create.mock.calls[0][0].publishTime).toBeInstanceOf(Date);
expect(result).toMatchObject({
categories: ['技术'],
categoriesResolved: [{ name: '技术', slug: '技术' }],
excerptText: '标题 正文',
tags: ['Milkdown'],
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
});
});
it('queries public list with publish status and maps response fields', async () => {
const builder = createQueryBuilderMock([
{
categoryItems: [{ name: '技术', slug: 'tech' }],
contentHtml: '<p>正文</p>',
id: '1',
isDeleted: false,
slug: 'demo',
status: 'publish',
tagItems: [{ name: 'Milkdown', slug: 'milkdown' }],
title: 'Demo',
},
]);
repository.createQueryBuilder.mockReturnValue(builder);
const result = await service.publicList({
pageNo: 1,
pageSize: 10,
});
expect(builder.andWhere).toHaveBeenCalledWith('article.status = :status', {
status: 'publish',
});
expect(result).toMatchObject({
list: [
{
categories: ['技术'],
categoriesResolved: [{ name: '技术', slug: 'tech' }],
tags: ['Milkdown'],
tagsResolved: [{ name: 'Milkdown', slug: 'milkdown' }],
},
],
total: 1,
});
});
it('imports WordPress public articles into local blog articles', async () => {
wordpressService.publicArticleList.mockResolvedValue({
list: [{ id: 50, slug: 'wordpress-post' }],
total: 1,
});
wordpressService.publicArticleDetail.mockResolvedValue({
authorName: 'WordPress 作者',
categoriesResolved: [{ id: 1, name: 'NAS', slug: 'nas' }],
contentHtml: '<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
date: '2026-06-05T10:30:00',
excerptText: '导入摘要',
id: 50,
slug: 'wordpress-post',
status: 'publish',
tagsResolved: [{ id: 2, name: 'Milkdown', slug: 'milkdown' }],
title: { rendered: '导入标题' },
});
repository.findOne.mockResolvedValue(null);
const result = await service.importFromWordpress({
pageNo: 1,
pageSize: 10,
});
expect(wordpressService.publicArticleList).toHaveBeenCalledWith({
pageNo: 1,
pageSize: 10,
});
expect(wordpressService.publicArticleDetail).toHaveBeenCalledWith({
id: 50,
slug: 'wordpress-post',
});
expect(repository.create).toHaveBeenCalledWith(
expect.objectContaining({
authorName: 'WordPress 作者',
categoryItems: [{ id: '1', name: 'NAS', slug: 'nas' }],
contentHtml: '<h1>导入标题</h1><p>导入正文</p>',
contentMarkdown: '# 导入标题\n\n导入正文',
excerpt: '导入摘要',
slug: 'wordpress-post',
status: 'publish',
tagItems: [{ id: '2', name: 'Milkdown', slug: 'milkdown' }],
title: '导入标题',
}),
);
expect(blogTermService.syncTerms).toHaveBeenCalledWith('category', [
{ id: '1', name: 'NAS', slug: 'nas' },
]);
expect(blogTermService.syncTerms).toHaveBeenCalledWith('tag', [
{ id: '2', name: 'Milkdown', slug: 'milkdown' },
]);
expect(repository.create.mock.calls[0][0].publishTime).toBeInstanceOf(Date);
expect(markdownService.sanitizeHtml).toHaveBeenCalledWith(
'<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
);
expect(markdownService.renderHtmlToMarkdown).toHaveBeenCalledWith(
'<h1>导入标题</h1><p>导入正文</p><script>alert(1)</script>',
);
expect(result).toMatchObject({
created: 1,
skipped: 0,
total: 1,
updated: 0,
items: [
{
action: 'created',
id: '1',
slug: 'wordpress-post',
title: '导入标题',
},
],
});
});
it('imports all WordPress article pages when all is enabled', async () => {
wordpressService.publicArticleList
.mockResolvedValueOnce({
list: [
{ id: 50, slug: 'wordpress-post-1' },
{ id: 51, slug: 'wordpress-post-2' },
],
total: 3,
})
.mockResolvedValueOnce({
list: [{ id: 52, slug: 'wordpress-post-3' }],
total: 3,
});
wordpressService.publicArticleDetail.mockImplementation(async ({ id }) => ({
contentHtml: `<p>导入正文 ${id}</p>`,
date: '2026-06-05T10:30:00',
id,
slug: `wordpress-post-${Number(id) - 49}`,
status: 'publish',
title: { rendered: `导入标题 ${id}` },
}));
repository.findOne.mockResolvedValue(null);
const result = await service.importFromWordpress({
all: true,
pageNo: 1,
pageSize: 2,
});
expect(wordpressService.publicArticleList).toHaveBeenNthCalledWith(1, {
pageNo: 1,
pageSize: 2,
});
expect(wordpressService.publicArticleList).toHaveBeenNthCalledWith(2, {
pageNo: 2,
pageSize: 2,
});
expect(wordpressService.publicArticleDetail).toHaveBeenCalledTimes(3);
expect(repository.save).toHaveBeenCalledTimes(3);
expect(result).toMatchObject({
created: 3,
pageCount: 2,
skipped: 0,
total: 3,
updated: 0,
});
});
it('uses local blog term service for category options', async () => {
blogTermService.options.mockResolvedValue({
list: [{ count: 2, id: 'tech', name: '技术', slug: 'tech' }],
total: 1,
});
const result = await service.categoryOptions({
pageNo: 1,
pageSize: 10,
search: '技',
});
expect(blogTermService.options).toHaveBeenCalledWith('category', {
pageNo: 1,
pageSize: 10,
search: '技',
});
expect(result).toEqual({
list: [
{
count: 2,
id: 'tech',
name: '技术',
slug: 'tech',
},
],
total: 1,
});
});
});
function createQueryBuilderMock(list: Partial<BlogArticle>[]) {
const builder = {
addOrderBy: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn().mockResolvedValue([list as BlogArticle[], list.length]),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
};
return builder;
}

View File

@ -0,0 +1,31 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('blog-init.sql', () => {
const sql = readFileSync(join(process.cwd(), 'sql/blog-init.sql'), 'utf8');
it('creates the local blog tables needed when DB_SYNC is disabled', () => {
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_article`');
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_term`');
expect(sql).toContain('CREATE TABLE IF NOT EXISTS `blog_theme_config`');
});
it('keeps article content and term relation columns aligned with entities', () => {
[
'`content_markdown` mediumtext',
'`content_html` mediumtext',
'`category_items` text',
'`tag_items` text',
'`publish_time` datetime',
'`is_deleted` tinyint(1)',
].forEach((column) => {
expect(sql).toContain(column);
});
});
it('keeps theme config columns aligned with BlogThemeConfig entity', () => {
expect(sql).toContain('`id` varchar(64) NOT NULL');
expect(sql).toContain('`config` longtext NOT NULL');
expect(sql).toContain("`source` varchar(255) NOT NULL DEFAULT 'local'");
});
});

View File

@ -0,0 +1,72 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('blog-menu.sql', () => {
const sql = readFileSync(join(process.cwd(), 'sql/blog-menu.sql'), 'utf8');
function expectMenuRoute(
name: string,
path: string,
component: string,
authCode: string,
) {
expect(sql).toContain(
`'${name}', '${path}', '${component}', NULL, '${authCode}', 'menu'`,
);
}
it('adds local blog admin menus and action permissions', () => {
[
'Blog',
'BlogArticle',
'BlogArticleCreate',
'BlogArticleEdit',
'BlogArticleDelete',
'BlogArticleImport',
'BlogCategory',
'BlogTag',
'BlogTheme',
'BlogThemeSave',
'BlogThemeImport',
].forEach((name) => {
expect(sql).toContain(`'${name}'`);
});
});
it('keeps database menu routes aligned with admin route components', () => {
expect(sql).toContain(
"'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog'",
);
expectMenuRoute(
'BlogArticle',
'/blog/article',
'/blog/article/list',
'Blog:Article:List',
);
expectMenuRoute(
'BlogCategory',
'/blog/category',
'/blog/category/list',
'Blog:Category:List',
);
expectMenuRoute(
'BlogTag',
'/blog/tag',
'/blog/tag/list',
'Blog:Tag:List',
);
expectMenuRoute(
'BlogTheme',
'/blog/theme',
'/blog/theme/config',
'Blog:Theme:List',
);
});
it('grants blog menus to admin roles without deleting existing role menus', () => {
expect(sql).toContain('INSERT IGNORE INTO `admin_role_menu`');
expect(sql).toContain("menu.`name` LIKE 'Blog%'");
expect(sql).toContain("role.`role_code` IN ('super', 'admin')");
expect(sql).not.toMatch(/DELETE\s+FROM\s+`admin_role_menu`/i);
});
});

View File

@ -0,0 +1,135 @@
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test } from '@nestjs/testing';
import { ToolsService } from '../../src/common';
import { BlogArticle } from '../../src/blog/blog-article.entity';
import { BlogTerm } from '../../src/blog/blog-term.entity';
import { BlogTermService } from '../../src/blog/blog-term.service';
describe('BlogTermService', () => {
let service: BlogTermService;
let termRepository: {
create: jest.Mock;
find: jest.Mock;
findOne: jest.Mock;
save: jest.Mock;
update: jest.Mock;
};
let articleRepository: {
find: jest.Mock;
};
beforeEach(async () => {
termRepository = {
create: jest.fn((payload) => payload),
find: jest.fn(),
findOne: jest.fn(),
save: jest.fn(async (payload) => ({ id: 'term-id', ...payload })),
update: jest.fn(),
};
articleRepository = {
find: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [
BlogTermService,
ToolsService,
{
provide: getRepositoryToken(BlogTerm),
useValue: termRepository,
},
{
provide: getRepositoryToken(BlogArticle),
useValue: articleRepository,
},
],
}).compile();
service = moduleRef.get(BlogTermService);
});
it('lists local terms with article counts and hide empty support', async () => {
termRepository.find.mockResolvedValue([
{ id: '1', kind: 'category', name: '技术', slug: 'tech' },
{ id: '2', kind: 'category', name: '生活', slug: 'life' },
]);
articleRepository.find.mockResolvedValue([
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
]);
const result = await service.page('category', {
hide_empty: true,
pageNo: 1,
pageSize: 10,
});
expect(termRepository.find).toHaveBeenCalledWith({
where: {
isDeleted: false,
kind: 'category',
},
});
expect(result).toMatchObject({
list: [
{
count: 2,
id: '1',
name: '技术',
slug: 'tech',
},
],
total: 1,
});
});
it('merges stored terms and article-only terms for article options', async () => {
termRepository.find.mockResolvedValue([
{ id: '1', kind: 'category', name: '草稿分类', slug: 'draft' },
]);
articleRepository.find.mockResolvedValue([
{ categoryItems: [{ name: '技术', slug: 'tech' }] },
]);
const result = await service.options('category', {
pageNo: 1,
pageSize: 10,
});
expect(result).toMatchObject({
list: [
{
count: 1,
id: 'tech',
name: '技术',
slug: 'tech',
},
{
count: 0,
id: '1',
name: '草稿分类',
slug: 'draft',
},
],
total: 2,
});
});
it('syncs article terms into local taxonomy once by slug', async () => {
termRepository.findOne
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: 'exists', name: 'Milkdown', slug: 'milkdown' });
await service.syncTerms('tag', [
{ name: 'Milkdown', slug: 'milkdown' },
{ name: 'Milkdown', slug: 'milkdown' },
]);
expect(termRepository.save).toHaveBeenCalledTimes(1);
expect(termRepository.create).toHaveBeenCalledWith({
kind: 'tag',
name: 'Milkdown',
slug: 'milkdown',
});
});
});

View File

@ -0,0 +1,183 @@
import { getRepositoryToken } from '@nestjs/typeorm';
import { Test } from '@nestjs/testing';
import { ToolsService } from '../../src/common';
import { BlogThemeConfig } from '../../src/blog/blog-theme-config.entity';
import { BlogThemeConfigService } from '../../src/blog/blog-theme-config.service';
import { WordpressService } from '../../src/wordpress/wordpress.service';
describe('BlogThemeConfigService', () => {
let service: BlogThemeConfigService;
let repository: {
create: jest.Mock;
findOne: jest.Mock;
save: jest.Mock;
};
let wordpressService: {
themeConfig: jest.Mock;
};
const wordpressThemeConfig = {
argonConfig: {
codeHighlight: {
breakLine: false,
enable: true,
hideLinenumber: false,
transparentLinenumber: false,
},
dateFormat: 'YMD',
disablePjax: true,
foldLongComments: false,
foldLongShuoshuo: false,
headroom: 'false',
language: 'zh_CN',
lazyload: {
effect: 'fadeIn',
threshold: 800,
},
pangu: 'article',
pjaxAnimationDuration: 600,
waterflowColumns: '1',
wpPath: '/',
zoomify: false,
},
backgroundDarkBrightness: 0.65,
backgroundDarkImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundDarkOpacity: 1,
backgroundImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundOpacity: 1,
bodyClass: ['home', 'blog', 'wp-theme-argon'],
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
sidebarMenu: [
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
site: {
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
authorName: 'KwiTsukasa',
description: '',
home: 'https://blog.kwitsukasa.top',
title: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: 4,
themeColor: '#c3a1ed',
themeColorRgb: '195,161,237',
themeVersion: '1.3.5',
};
beforeEach(async () => {
repository = {
create: jest.fn((payload) => payload),
findOne: jest.fn(),
save: jest.fn(async (payload) => payload),
};
wordpressService = {
themeConfig: jest.fn(),
};
const moduleRef = await Test.createTestingModule({
providers: [
BlogThemeConfigService,
ToolsService,
{
provide: getRepositoryToken(BlogThemeConfig),
useValue: repository,
},
{
provide: WordpressService,
useValue: wordpressService,
},
],
}).compile();
service = moduleRef.get(BlogThemeConfigService);
});
it('returns a local default config when no saved config exists', async () => {
repository.findOne.mockResolvedValue(null);
const config = await service.publicConfig();
expect(config).toMatchObject({
site: {
authorAvatar: '/argon/theme/profile.jpg',
authorName: 'KwiTsukasa',
title: 'KwiTsukasa的小站',
},
sidebarMenu: [
{
href: '/',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: '/admin',
icon: 'fa-user',
label: '管理',
},
],
themeCardRadius: 4,
themeColor: '#c3a1ed',
backgroundDarkBrightness: 0.65,
backgroundDarkImage: '/argon/theme/img-2-1200x1000.jpg',
backgroundDarkOpacity: 1,
backgroundImage: '/argon/theme/img-2-1200x1000.jpg',
backgroundOpacity: 1,
});
expect(wordpressService.themeConfig).not.toHaveBeenCalled();
});
it('saves local theme config', async () => {
repository.findOne.mockResolvedValue(null);
const result = await service.save({
config: wordpressThemeConfig,
source: 'local-admin',
});
expect(repository.create).toHaveBeenCalledWith({
config: wordpressThemeConfig,
id: 'argon',
source: 'local-admin',
});
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
config: wordpressThemeConfig,
id: 'argon',
source: 'local-admin',
}),
);
expect(result).toBe(wordpressThemeConfig);
});
it('imports WordPress theme config into local storage', async () => {
repository.findOne.mockResolvedValue({
id: 'argon',
source: 'local',
});
wordpressService.themeConfig.mockResolvedValue(wordpressThemeConfig);
const result = await service.importFromWordpress();
expect(wordpressService.themeConfig).toHaveBeenCalledWith();
expect(repository.save).toHaveBeenCalledWith(
expect.objectContaining({
config: wordpressThemeConfig,
id: 'argon',
source: 'wordpress',
}),
);
expect(result).toBe(wordpressThemeConfig);
});
});

View File

@ -0,0 +1,38 @@
import { MarkdownService } from '../../src/common';
describe('MarkdownService', () => {
let service: MarkdownService;
beforeEach(() => {
service = new MarkdownService();
});
it('embeds, extracts and strips markdown source marker', () => {
const markdown = '# 标题\n\n正文';
const html = service.embedSourceHtml('<h1>标题</h1>', markdown);
expect(service.extractSource(html)).toBe(markdown);
expect(service.stripSourceMarker(html)).toBe('<h1>标题</h1>');
});
it('sanitizes imported html through the html sanitize processor', async () => {
const process = jest
.fn()
.mockResolvedValue('<p>正文</p>');
(service as any).sanitizeHtmlProcessorPromise = Promise.resolve({
process,
});
const html = await service.sanitizeHtml(
'<p onclick="alert(1)">正文</p><script>alert(1)</script>',
);
expect(process).toHaveBeenCalledWith(
'<p onclick="alert(1)">正文</p><script>alert(1)</script>',
);
expect(html).toContain('<p>正文</p>');
expect(html).not.toContain('onclick');
expect(html).not.toContain('<script>');
});
});

View File

@ -78,9 +78,7 @@ describe('QqbotCommandParserService FFLogs parser', () => {
encounterName: '上位护锁刃龙', encounterName: '上位护锁刃龙',
serverSlug: '琥珀原', serverSlug: '琥珀原',
}); });
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith( expect(dictService.getDictItemsByKey).not.toHaveBeenCalled();
'FFLOGS_ENCOUNTER_LABEL',
);
}); });
it('keeps character summary parsing when no encounter is provided', async () => { it('keeps character summary parsing when no encounter is provided', async () => {

View File

@ -0,0 +1,310 @@
import { ConfigService } from '@nestjs/config';
import { MarkdownService, ToolsService } from '../../src/common';
import { WordpressService } from '../../src/wordpress/wordpress.service';
describe('WordpressService theme config', () => {
const rootPayload = {
description: '',
home: 'https://blog.kwitsukasa.top',
name: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
};
const html = `
<!doctype html>
<html lang="zh-Hans" class="no-js triple-column immersion-color toolbar-blur article-header-style-default ">
<head>
<meta name="theme-color" content="#c3a1ed">
<meta name="theme-color-rgb" content="195,161,237">
<meta name="argon-enable-custom-theme-color" content="true">
<meta name="theme-card-radius" content="4">
<meta name="theme-version" content="1.3.5">
</head>
<body class="home blog wp-theme-argon">
<div id="leftbar_overview_author_image" style="background-image: url(http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg)" class="rounded-circle shadow-sm" alt="avatar"></div>
<h6 id="leftbar_overview_author_name">KwiTsukasa</h6>
<ul id="leftbar_part1_menu" class="leftbar-menu">
<li><a href="https://blog.kwitsukasa.top"><i class="fa fa-home"></i> </a></li>
<li><a href="http://blog.kwitsukasa.top/wp-admin/"><i class="fa fa-user"></i> </a></li>
</ul>
<style>
#content:before {
background: url(https://s3.kwitsukasa.top/images/bg-%E5%86%AC%E6%BB%9A%E6%BB%9A.png);
background-position: center;
background-size: cover;
opacity: 1;
}
html.darkmode #content:before {
filter: brightness(0.65);
}
#content:after {
background: url(https://s3.kwitsukasa.top/images/bg-%E5%86%AC%E6%BB%9A%E6%BB%9A.png);
opacity: 0;
}
html.darkmode #content:after {
opacity: 1;
}
</style>
<script>
var argonConfig = {
wp_path: "/",
language: "zh_CN",
dateFormat: "YMD",
zoomify: false,
pangu: "article",
lazyload: {
threshold: 800,
effect: "fadeIn"
},
fold_long_comments: false,
fold_long_shuoshuo: false,
disable_pjax: false,
pjax_animation_durtion: 600,
headroom: "false",
waterflow_columns: "1",
code_highlight: {
enable: true,
hide_linenumber: false,
transparent_linenumber: false,
break_line: false
}
};
var darkmodeAutoSwitch = "alwayson";
</script>
</body>
</html>
`;
let service: WordpressService;
let fetchSpy: jest.SpyInstance;
let markdownService: MarkdownService;
beforeEach(() => {
markdownService = new MarkdownService();
jest
.spyOn(markdownService, 'renderToHtml')
.mockResolvedValue('<h1>标题</h1>\n<p>正文</p>');
service = new WordpressService(
new ConfigService({
WORDPRESS_BASE_URL: 'https://blog.kwitsukasa.top',
}),
markdownService,
new ToolsService(),
);
fetchSpy = jest
.spyOn(globalThis, 'fetch')
.mockImplementation(async (url: URL | RequestInfo) => {
const target = `${url}`;
if (target.includes('rest_route=/')) {
return new Response(JSON.stringify(rootPayload), {
headers: { 'Content-Type': 'application/json' },
status: 200,
});
}
return new Response(html, {
headers: { 'Content-Type': 'text/html' },
status: 200,
});
});
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('extracts Argon theme config from WordPress REST root and homepage html', async () => {
const config = await service.themeConfig();
expect(config).toMatchObject({
backgroundDarkBrightness: 0.65,
backgroundDarkImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundDarkOpacity: 1,
backgroundImage: 'https://s3.kwitsukasa.top/images/bg-冬滚滚.png',
backgroundOpacity: 1,
bodyClass: ['home', 'blog', 'wp-theme-argon'],
darkmodeAutoSwitch: 'alwayson',
enableCustomThemeColor: true,
htmlClass: [
'triple-column',
'immersion-color',
'toolbar-blur',
'article-header-style-default',
],
sidebarMenu: [
{
external: true,
href: 'https://blog.kwitsukasa.top',
icon: 'fa-home',
label: '首页',
},
{
external: true,
href: 'http://blog.kwitsukasa.top/wp-admin/',
icon: 'fa-user',
label: '管理',
},
],
site: {
authorAvatar: 'http://s3.kwitsukasa.top/images/avatar-tsukasa-1.jpg',
authorName: 'KwiTsukasa',
description: '',
home: 'https://blog.kwitsukasa.top',
title: 'KwiTsukasa的小站',
url: 'https://blog.kwitsukasa.top',
},
themeCardRadius: 4,
themeColor: '#c3a1ed',
themeColorRgb: '195,161,237',
themeVersion: '1.3.5',
});
expect(config.argonConfig).toMatchObject({
codeHighlight: {
breakLine: false,
enable: true,
hideLinenumber: false,
transparentLinenumber: false,
},
dateFormat: 'YMD',
disablePjax: false,
headroom: 'false',
language: 'zh_CN',
lazyload: {
effect: 'fadeIn',
threshold: 800,
},
pangu: 'article',
pjaxAnimationDuration: 600,
waterflowColumns: '1',
wpPath: '/',
zoomify: false,
});
});
it('renders markdown article content before saving to WordPress', async () => {
fetchSpy.mockImplementation(async (url: URL | RequestInfo, init?: any) => {
const target = `${url}`;
if (target.includes('/wp-json/wp/v2/posts')) {
const body = JSON.parse(init.body);
return new Response(
JSON.stringify({
content: { raw: body.content },
id: 1,
title: { raw: body.title },
}),
{
headers: { 'Content-Type': 'application/json' },
status: 200,
},
);
}
return new Response(JSON.stringify({ id: 1 }), { status: 200 });
});
const result = await service.articleSave(
{
content: '# 标题\n\n正文<script>alert(1)</script>',
contentFormat: 'markdown',
title: 'Markdown 文章',
},
{
nonce: 'rest-nonce',
cookie: 'wordpress_logged_in_demo=1',
},
);
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/wp-json/wp/v2/posts'),
expect.objectContaining({
method: 'POST',
}),
);
const [, init] = fetchSpy.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.content).toContain('<h1>标题</h1>');
expect(body.content).not.toContain('<script>');
expect(body.content).toContain('kt-markdown-source:');
expect(markdownService.renderToHtml).toHaveBeenCalledWith(
'# 标题\n\n正文<script>alert(1)</script>',
);
expect(result.contentMarkdown).toBe(
'# 标题\n\n正文<script>alert(1)</script>',
);
});
it('normalizes public article list from WordPress view responses', async () => {
fetchSpy.mockImplementation(async (_url: URL | RequestInfo, init?: any) => {
expect(init?.headers?.Authorization).toBeUndefined();
expect(init?.headers?.Cookie).toBeUndefined();
return new Response(
JSON.stringify([
{
_embedded: {
author: [{ name: '作者' }],
'wp:featuredmedia': [{ source_url: 'https://img.demo/cover.jpg' }],
'wp:term': [
[
{
count: 2,
id: 10,
name: '技术',
slug: 'tech',
taxonomy: 'category',
},
],
[
{
count: 1,
id: 20,
name: 'Milkdown',
slug: 'milkdown',
taxonomy: 'post_tag',
},
],
],
},
content: {
rendered: '<h1>标题</h1>',
},
excerpt: {
rendered: '<p>摘要</p>',
},
id: 1,
slug: 'demo',
title: {
rendered: '公开文章',
},
},
]),
{
headers: {
'Content-Type': 'application/json',
'x-wp-total': '1',
},
status: 200,
},
);
});
const result = await service.publicArticleList({
pageNo: 1,
pageSize: 10,
});
expect(fetchSpy).toHaveBeenCalledWith(
expect.stringContaining('/wp-json/wp/v2/posts'),
expect.objectContaining({
method: 'GET',
}),
);
expect(result.total).toBe(1);
expect(result.list[0]).toMatchObject({
authorName: '作者',
contentHtml: '<h1>标题</h1>',
cover: 'https://img.demo/cover.jpg',
excerptText: '摘要',
categoriesResolved: [{ id: 10, name: '技术', slug: 'tech' }],
tagsResolved: [{ id: 20, name: 'Milkdown', slug: 'milkdown' }],
});
});
});