Compare commits
3 Commits
7409d2329b
...
1a008ea860
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a008ea860 | |||
| 97f921344f | |||
| 2d8b47381a |
1
Jenkinsfile
vendored
1
Jenkinsfile
vendored
@ -33,6 +33,7 @@ def requiredRuntimeEnvKeys() {
|
||||
'DB_PASSWORD',
|
||||
'DB_DATABASE',
|
||||
'ADMIN_TOKEN_SECRET',
|
||||
'WORDPRESS_BASE_URL',
|
||||
'FFLOGS_CLIENT_ID',
|
||||
'FFLOGS_CLIENT_SECRET',
|
||||
]
|
||||
|
||||
@ -7,7 +7,7 @@ metadata:
|
||||
app: kt-template-online-api
|
||||
spec:
|
||||
replicas: 1
|
||||
revisionHistoryLimit: 5
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
|
||||
10
package.json
10
package.json
@ -45,9 +45,19 @@
|
||||
"pino-http": "^11.0.0",
|
||||
"pino-loki": "^3.0.0",
|
||||
"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",
|
||||
"svg-captcha": "^1.4.0",
|
||||
"typeorm": "^0.3.28",
|
||||
"unified": "^11.0.5",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
997
pnpm-lock.yaml
997
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
58
sql/blog-init.sql
Normal file
58
sql/blog-init.sql
Normal 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
43
sql/blog-menu.sql
Normal 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;
|
||||
@ -189,6 +189,7 @@ VALUES
|
||||
(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),
|
||||
@ -197,6 +198,9 @@ VALUES
|
||||
(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),
|
||||
(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),
|
||||
(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),
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
SaveBodyInterceptor,
|
||||
} from './common';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { BlogModule } from './blog/blog.module';
|
||||
import { WordpressModule } from './wordpress/wordpress.module';
|
||||
import { QqbotModule } from './qqbot/qqbot.module';
|
||||
|
||||
@ -64,6 +65,7 @@ import { QqbotModule } from './qqbot/qqbot.module';
|
||||
MinioClientModule,
|
||||
CommonModule,
|
||||
AdminModule,
|
||||
BlogModule,
|
||||
WordpressModule,
|
||||
QqbotModule,
|
||||
],
|
||||
|
||||
135
src/blog/blog-article.controller.ts
Normal file
135
src/blog/blog-article.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
120
src/blog/blog-article.dto.ts
Normal file
120
src/blog/blog-article.dto.ts
Normal 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;
|
||||
}
|
||||
159
src/blog/blog-article.entity.ts
Normal file
159
src/blog/blog-article.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
450
src/blog/blog-article.service.ts
Normal file
450
src/blog/blog-article.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
130
src/blog/blog-term.controller.ts
Normal file
130
src/blog/blog-term.controller.ts
Normal 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
59
src/blog/blog-term.dto.ts
Normal 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;
|
||||
}
|
||||
81
src/blog/blog-term.entity.ts
Normal file
81
src/blog/blog-term.entity.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
302
src/blog/blog-term.service.ts
Normal file
302
src/blog/blog-term.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
src/blog/blog-theme-config.controller.ts
Normal file
51
src/blog/blog-theme-config.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
14
src/blog/blog-theme-config.dto.ts
Normal file
14
src/blog/blog-theme-config.dto.ts
Normal 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;
|
||||
}
|
||||
44
src/blog/blog-theme-config.entity.ts
Normal file
44
src/blog/blog-theme-config.entity.ts
Normal 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;
|
||||
}
|
||||
146
src/blog/blog-theme-config.service.ts
Normal file
146
src/blog/blog-theme-config.service.ts
Normal 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
31
src/blog/blog.module.ts
Normal 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 {}
|
||||
@ -1,10 +1,11 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { LokiLogPublisherService } from './logger/loki-log-publisher.service';
|
||||
import { MarkdownService } from './services/markdown.service';
|
||||
import { ToolsService } from './services/tool.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
exports: [LokiLogPublisherService, ToolsService],
|
||||
providers: [LokiLogPublisherService, ToolsService],
|
||||
exports: [LokiLogPublisherService, MarkdownService, ToolsService],
|
||||
providers: [LokiLogPublisherService, MarkdownService, ToolsService],
|
||||
})
|
||||
export class CommonModule {}
|
||||
|
||||
@ -9,6 +9,7 @@ export * from './interceptors/save-body.interceptor';
|
||||
export * from './logger/loki-log-publisher.service';
|
||||
export * from './logger/pino-logger.config';
|
||||
export * from './response/vben-response';
|
||||
export * from './services/markdown.service';
|
||||
export * from './services/tool.service';
|
||||
export * from './snowflake/snowflake-id';
|
||||
export * from './snowflake/snowflake-id.subscriber';
|
||||
|
||||
234
src/common/services/markdown.service.ts
Normal file
234
src/common/services/markdown.service.ts
Normal 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],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -132,6 +132,17 @@ export class ToolsService {
|
||||
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(
|
||||
value: number | string | undefined | null,
|
||||
fallback: number,
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
import { ApiHeader, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
|
||||
import type { Request } from 'express';
|
||||
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
|
||||
import { vbenSuccess } from '@/common';
|
||||
import { Public, vbenSuccess } from '@/common';
|
||||
import {
|
||||
WordpressArticleBodyDto,
|
||||
WordpressArticleListQueryDto,
|
||||
@ -37,6 +37,33 @@ import { WordpressService } from './wordpress.service';
|
||||
export class WordpressArticleController {
|
||||
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')
|
||||
@ApiOperation({ summary: '获取 WordPress 文章分页列表' })
|
||||
async list(
|
||||
|
||||
21
src/wordpress/wordpress-theme.controller.ts
Normal file
21
src/wordpress/wordpress-theme.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@ -77,6 +77,12 @@ export class WordpressArticleBodyDto {
|
||||
})
|
||||
content?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: ['html', 'markdown'],
|
||||
description: '文章内容格式,markdown 会在后端转换为安全 HTML',
|
||||
})
|
||||
contentFormat?: 'html' | 'markdown';
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: '文章摘要',
|
||||
})
|
||||
|
||||
@ -5,6 +5,7 @@ import { WordpressAuthController } from './wordpress-auth.controller';
|
||||
import { WordpressCategoryController } from './wordpress-category.controller';
|
||||
import { WordpressService } from './wordpress.service';
|
||||
import { WordpressTagController } from './wordpress-tag.controller';
|
||||
import { WordpressThemeController } from './wordpress-theme.controller';
|
||||
|
||||
@Module({
|
||||
imports: [AdminAuthGuardModule],
|
||||
@ -13,6 +14,7 @@ import { WordpressTagController } from './wordpress-tag.controller';
|
||||
WordpressArticleController,
|
||||
WordpressTagController,
|
||||
WordpressCategoryController,
|
||||
WordpressThemeController,
|
||||
],
|
||||
providers: [WordpressService],
|
||||
exports: [WordpressService],
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response as ExpressResponse } from 'express';
|
||||
import { throwVbenError, ToolsService } from '@/common';
|
||||
import { MarkdownService, throwVbenError, ToolsService } from '@/common';
|
||||
import type {
|
||||
WordpressArticleBodyDto,
|
||||
WordpressArticleListQueryDto,
|
||||
@ -17,6 +17,8 @@ import type {
|
||||
WordpressPagedQueryDto,
|
||||
WordpressRequestOptions,
|
||||
WordpressResponse,
|
||||
WordpressArgonThemeConfig,
|
||||
WordpressArgonMenuItem,
|
||||
} from './wordpress.types';
|
||||
|
||||
const WORDPRESS_COOKIE_PREFIXES = [
|
||||
@ -34,6 +36,7 @@ export class WordpressService {
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly markdownService: MarkdownService,
|
||||
private readonly toolsService: ToolsService,
|
||||
) {}
|
||||
|
||||
@ -185,7 +188,7 @@ export class WordpressService {
|
||||
});
|
||||
|
||||
return {
|
||||
list: response.data,
|
||||
list: response.data.map((item) => this.normalizeArticleResponse(item)),
|
||||
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) {
|
||||
const response = await this.request('/wp-json/wp/v2/posts', {
|
||||
auth,
|
||||
body: this.getArticleBody(body),
|
||||
body: await this.getArticleBody(body),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return this.normalizeArticleResponse(response.data);
|
||||
}
|
||||
|
||||
async articleUpdate(
|
||||
@ -217,11 +220,11 @@ export class WordpressService {
|
||||
) {
|
||||
const response = await this.request(`/wp-json/wp/v2/posts/${body.id}`, {
|
||||
auth,
|
||||
body: this.getArticleBody(body),
|
||||
body: await this.getArticleBody(body),
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return this.normalizeArticleResponse(response.data);
|
||||
}
|
||||
|
||||
async articleRemove(
|
||||
@ -240,6 +243,64 @@ export class WordpressService {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
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(
|
||||
path: string,
|
||||
query: WordpressTermListQueryDto,
|
||||
@ -385,6 +457,20 @@ export class WordpressService {
|
||||
): Promise<WordpressResponse<T>> {
|
||||
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 timer = setTimeout(() => controller.abort(), this.getTimeout());
|
||||
|
||||
@ -568,9 +654,9 @@ export class WordpressService {
|
||||
}
|
||||
}
|
||||
|
||||
private assertAuthContext(auth: WordpressAuthContext) {
|
||||
const hasToken = !!auth.authorization;
|
||||
const hasCookieLogin = !!auth.cookie && !!auth.nonce;
|
||||
private assertAuthContext(auth?: WordpressAuthContext) {
|
||||
const hasToken = !!auth?.authorization;
|
||||
const hasCookieLogin = !!auth?.cookie && !!auth?.nonce;
|
||||
|
||||
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> = {
|
||||
Accept: 'application/json',
|
||||
};
|
||||
@ -590,15 +676,15 @@ export class WordpressService {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
if (auth.authorization) {
|
||||
if (auth?.authorization) {
|
||||
headers.Authorization = auth.authorization;
|
||||
}
|
||||
|
||||
if (auth.cookie) {
|
||||
if (auth?.cookie) {
|
||||
headers.Cookie = auth.cookie;
|
||||
}
|
||||
|
||||
if (auth.nonce) {
|
||||
if (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({
|
||||
categories: this.normalizeIdList(body.categories),
|
||||
content: body.content,
|
||||
content: await this.getArticleContent(body),
|
||||
excerpt: body.excerpt,
|
||||
featured_media: body.featured_media,
|
||||
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) {
|
||||
return this.toolsService.pickDefined({
|
||||
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(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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) {
|
||||
if (data?.message) return data.message;
|
||||
if (typeof data === 'string' && data) return data;
|
||||
|
||||
@ -46,7 +46,7 @@ export type WordpressPagedQueryDto =
|
||||
| WordpressTermListQueryDto;
|
||||
|
||||
export type WordpressRequestOptions = {
|
||||
auth: WordpressAuthContext;
|
||||
auth?: WordpressAuthContext;
|
||||
body?: Record<string, unknown>;
|
||||
method?: 'GET' | 'POST' | 'DELETE';
|
||||
query?: Record<string, unknown>;
|
||||
@ -56,3 +56,59 @@ export type WordpressResponse<T> = {
|
||||
data: T;
|
||||
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;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
ApiExceptionFilter,
|
||||
IS_PUBLIC_KEY,
|
||||
SaveBodyInterceptor,
|
||||
ToolsService,
|
||||
} 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 { MinioClientService } from '../src/minio/minio.service';
|
||||
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 { WordpressService } from '../src/wordpress/wordpress.service';
|
||||
import { WordpressTagController } from '../src/wordpress/wordpress-tag.controller';
|
||||
import { WordpressThemeController } from '../src/wordpress/wordpress-theme.controller';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
import {
|
||||
collectControllerRoutes,
|
||||
@ -154,12 +162,56 @@ const wordpressArticle = {
|
||||
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 = {
|
||||
id: 1,
|
||||
name: 'WordPress 分类',
|
||||
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 = {
|
||||
all: jest.fn(),
|
||||
page: jest.fn(),
|
||||
@ -219,6 +271,34 @@ const minioServiceMock = {
|
||||
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 = {
|
||||
getAuthContext: jest.fn(),
|
||||
loginWithConfiguredAdmin: jest.fn(),
|
||||
@ -230,6 +310,8 @@ const wordpressServiceMock = {
|
||||
articleSave: jest.fn(),
|
||||
articleUpdate: jest.fn(),
|
||||
articleRemove: jest.fn(),
|
||||
publicArticleList: jest.fn(),
|
||||
publicArticleDetail: jest.fn(),
|
||||
tagList: jest.fn(),
|
||||
tagDetail: jest.fn(),
|
||||
tagSave: jest.fn(),
|
||||
@ -240,6 +322,7 @@ const wordpressServiceMock = {
|
||||
categorySave: jest.fn(),
|
||||
categoryUpdate: jest.fn(),
|
||||
categoryRemove: jest.fn(),
|
||||
themeConfig: jest.fn(),
|
||||
};
|
||||
|
||||
const controllerClasses = [
|
||||
@ -247,11 +330,15 @@ const controllerClasses = [
|
||||
ComponentController,
|
||||
DictController,
|
||||
SystemLogController,
|
||||
BlogArticleController,
|
||||
BlogTermController,
|
||||
BlogThemeConfigController,
|
||||
MinioClientController,
|
||||
WordpressAuthController,
|
||||
WordpressArticleController,
|
||||
WordpressTagController,
|
||||
WordpressCategoryController,
|
||||
WordpressThemeController,
|
||||
];
|
||||
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) => {
|
||||
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) => {
|
||||
wordpressServiceMock.articleSave.mockResolvedValue(wordpressArticle);
|
||||
|
||||
@ -1303,6 +1955,21 @@ const routeTestCases: Record<string, RouteTestCase> = {
|
||||
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)', () => {
|
||||
@ -1332,6 +1999,18 @@ describe('KT Template Online API (e2e)', () => {
|
||||
provide: SystemLogService,
|
||||
useValue: systemLogServiceMock,
|
||||
},
|
||||
{
|
||||
provide: BlogArticleService,
|
||||
useValue: blogArticleServiceMock,
|
||||
},
|
||||
{
|
||||
provide: BlogTermService,
|
||||
useValue: blogTermServiceMock,
|
||||
},
|
||||
{
|
||||
provide: BlogThemeConfigService,
|
||||
useValue: blogThemeConfigServiceMock,
|
||||
},
|
||||
{
|
||||
provide: MinioClientService,
|
||||
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', () => {
|
||||
controllerRoutes.forEach((route) => {
|
||||
const key = routeKey(route);
|
||||
|
||||
323
test/blog/blog-article.service.spec.ts
Normal file
323
test/blog/blog-article.service.spec.ts
Normal 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;
|
||||
}
|
||||
31
test/blog/blog-init-sql.spec.ts
Normal file
31
test/blog/blog-init-sql.spec.ts
Normal 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'");
|
||||
});
|
||||
});
|
||||
72
test/blog/blog-menu-sql.spec.ts
Normal file
72
test/blog/blog-menu-sql.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
135
test/blog/blog-term.service.spec.ts
Normal file
135
test/blog/blog-term.service.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
183
test/blog/blog-theme-config.service.spec.ts
Normal file
183
test/blog/blog-theme-config.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
test/common/markdown.service.spec.ts
Normal file
38
test/common/markdown.service.spec.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
@ -78,9 +78,7 @@ describe('QqbotCommandParserService FFLogs parser', () => {
|
||||
encounterName: '上位护锁刃龙',
|
||||
serverSlug: '琥珀原',
|
||||
});
|
||||
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith(
|
||||
'FFLOGS_ENCOUNTER_LABEL',
|
||||
);
|
||||
expect(dictService.getDictItemsByKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps character summary parsing when no encounter is provided', async () => {
|
||||
|
||||
310
test/wordpress/wordpress.service.spec.ts
Normal file
310
test/wordpress/wordpress.service.spec.ts
Normal 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' }],
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user