diff --git a/src/modules/aggregate/aggregate.interface.ts b/src/modules/aggregate/aggregate.interface.ts index 04b08fc1..01594824 100644 --- a/src/modules/aggregate/aggregate.interface.ts +++ b/src/modules/aggregate/aggregate.interface.ts @@ -8,5 +8,6 @@ export interface RSSProps { link: string title: string text: string + id: string }[] } diff --git a/src/modules/aggregate/aggregate.service.ts b/src/modules/aggregate/aggregate.service.ts index 6e6ec18a..c8b93260 100644 --- a/src/modules/aggregate/aggregate.service.ts +++ b/src/modules/aggregate/aggregate.service.ts @@ -288,6 +288,7 @@ export class AggregateService { const postsRss: RSSProps['data'] = posts.map((post) => { return { + id: post._id, title: post.title, text: post.text, created: post.created!, @@ -303,6 +304,7 @@ export class AggregateService { ? dayjs(note.secret).isAfter(new Date()) : false return { + id: note._id, title: note.title, text: isSecret ? '这篇文章暂时没有公开呢' : note.text, created: note.created!, diff --git a/src/modules/configs/configs.dto.ts b/src/modules/configs/configs.dto.ts index dbf063e8..b0450eb0 100644 --- a/src/modules/configs/configs.dto.ts +++ b/src/modules/configs/configs.dto.ts @@ -257,3 +257,11 @@ export class FriendLinkOptionsDto { @JSONSchemaToggleField('允许申请友链') allowApply: boolean } + +@JSONSchema({ title: '文本设定' }) +export class TextOptionsDto { + @IsBoolean() + @IsOptional() + @JSONSchemaToggleField('开启文本宏替换') + macros: boolean +} diff --git a/src/modules/configs/configs.interface.ts b/src/modules/configs/configs.interface.ts index 02c47548..7af30392 100644 --- a/src/modules/configs/configs.interface.ts +++ b/src/modules/configs/configs.interface.ts @@ -12,6 +12,7 @@ import { MailOptionsDto, SeoDto, TerminalOptionsDto, + TextOptionsDto, UrlDto, } from './configs.dto' @@ -49,6 +50,10 @@ export abstract class IConfig { @Type(() => TerminalOptionsDto) @ValidateNested() terminalOptions: TerminalOptionsDto + + @Type(() => TextOptionsDto) + @ValidateNested() + textOptions: TextOptionsDto } export type IConfigKeys = keyof IConfig diff --git a/src/modules/configs/configs.service.ts b/src/modules/configs/configs.service.ts index 7b67900c..f47e5203 100644 --- a/src/modules/configs/configs.service.ts +++ b/src/modules/configs/configs.service.ts @@ -74,6 +74,9 @@ const generateDefaultConfig: () => IConfig = () => ({ terminalOptions: { enable: false, }, + textOptions: { + macros: false, + }, }) @Injectable() diff --git a/src/modules/feed/feed.controller.ts b/src/modules/feed/feed.controller.ts index 73926ab6..71c42543 100644 --- a/src/modules/feed/feed.controller.ts +++ b/src/modules/feed/feed.controller.ts @@ -49,8 +49,8 @@ export class FeedController { ${title} ${xss(url)} - ${data - .map((item) => { + ${await Promise.all( + data.map(async (item) => { return ` ${item.title} @@ -61,7 +61,9 @@ export class FeedController { ${`
该渲染由 marked 生成, 可能存在部分语句不通或者排版问题, 最佳体验请前往: ${xss(item.link)}
- ${this.markdownService.renderMarkdownContent(item.text)} + ${await this.markdownService + .renderArticle(item.id) + .then((res) => res.html)}

看完了?说点什么呢

`} @@ -69,8 +71,8 @@ export class FeedController {
` - }) - .join('')} + }), + ).then((res) => res.join(''))} ` return xml diff --git a/src/modules/markdown/markdown.controller.ts b/src/modules/markdown/markdown.controller.ts index ff89f518..7d116f92 100644 --- a/src/modules/markdown/markdown.controller.ts +++ b/src/modules/markdown/markdown.controller.ts @@ -28,7 +28,6 @@ import { HTTPDecorators } from '~/common/decorator/http.decorator' import { ApiName } from '~/common/decorator/openapi.decorator' import { IsMaster } from '~/common/decorator/role.decorator' import { ArticleTypeEnum } from '~/constants/article.constant' -import { TextMacroService } from '~/processors/helper/helper.macro.service' import { MongoIdDto } from '~/shared/dto/id.dto' import { getShortDateTime } from '~/utils' @@ -52,7 +51,6 @@ export class MarkdownController { private readonly service: MarkdownService, private readonly configs: ConfigsService, - private readonly macroService: TextMacroService, ) {} @Post('/import') diff --git a/src/modules/note/note.controller.ts b/src/modules/note/note.controller.ts index 2b5451f1..f777df19 100644 --- a/src/modules/note/note.controller.ts +++ b/src/modules/note/note.controller.ts @@ -21,6 +21,7 @@ import { IsMaster } from '~/common/decorator/role.decorator' import { VisitDocument } from '~/common/decorator/update-count.decorator' import { CannotFindException } from '~/common/exceptions/cant-find.exception' import { CountingService } from '~/processors/helper/helper.counting.service' +import { TextMacroService } from '~/processors/helper/helper.macro.service' import { IntIdOrMongoIdDto, MongoIdDto } from '~/shared/dto/id.dto' import { addHidePasswordAndHideCondition, @@ -42,6 +43,8 @@ export class NoteController { constructor( private readonly noteService: NoteService, private readonly countingService: CountingService, + + private readonly macrosService: TextMacroService, ) {} @Get('/latest') @@ -228,6 +231,11 @@ export class NoteController { if (!current) { throw new CannotFindException() } + + current.text = await this.macrosService.replaceTextMacro( + current.text, + current, + ) if ( !this.noteService.checkPasswordToAccess(current, password) && !isMaster @@ -263,17 +271,4 @@ export class NoteController { delete current.password return { data: current, next, prev } } - - @ApiOperation({ summary: '根据 nid 修改' }) - @Put('/nid/:nid') - @Auth() - async modifyNoteByNid(@Param() params: NidType, @Body() body: NoteModel) { - const id = await this.noteService.getIdByNid(params.nid) - if (!id) { - throw new CannotFindException() - } - return await this.modify(body, { - id, - }) - } } diff --git a/src/modules/note/note.service.ts b/src/modules/note/note.service.ts index 217c692d..7a486f7a 100644 --- a/src/modules/note/note.service.ts +++ b/src/modules/note/note.service.ts @@ -9,6 +9,7 @@ import { BusinessEvents, EventScope } from '~/constants/business-event.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { ImageService } from '~/processors/helper/helper.image.service' +import { TextMacroService } from '~/processors/helper/helper.macro.service' import { InjectModel } from '~/transformers/model.transformer' import { deleteKeys } from '~/utils' @@ -21,6 +22,8 @@ export class NoteService { private readonly noteModel: MongooseModel, private readonly imageService: ImageService, private readonly eventManager: EventManagerService, + + private readonly textMacrosService: TextMacroService, ) { this.needCreateDefult() } @@ -45,6 +48,11 @@ export class NoteService { throw new CannotFindException() } + latest.text = await this.textMacrosService.replaceTextMacro( + latest.text, + latest, + ) + // 是否存在上一条记录 (旧记录) // 统一: next 为较老的记录 prev 为较新的记录 // FIXME may cause bug @@ -99,9 +107,15 @@ export class NoteService { ) : this.eventManager.broadcast( BusinessEvents.NOTE_CREATE, - doc.toJSON(), { - scope: EventScope.TO_SYSTEM_VISITOR, + ...doc.toJSON(), + text: await this.textMacrosService.replaceTextMacro( + doc.text, + doc, + ), + }, + { + scope: EventScope.TO_VISITOR, }, ), ]) @@ -129,14 +143,28 @@ export class NoteService { }) await Promise.all([ this.imageService.recordImageDimensions(this.noteModel, id), - this.model.findById(id).then((doc) => { + this.model.findById(id).then(async (doc) => { if (!doc) { return } delete doc.password this.eventManager.broadcast(BusinessEvents.NOTE_UPDATE, doc, { - scope: EventScope.TO_SYSTEM_VISITOR, + scope: EventScope.TO_SYSTEM, }) + + this.eventManager.broadcast( + BusinessEvents.NOTE_UPDATE, + { + ...doc, + text: await this.textMacrosService.replaceTextMacro( + doc.text, + doc, + ), + }, + { + scope: EventScope.TO_VISITOR, + }, + ) }), ]) }) diff --git a/src/modules/post/post.service.ts b/src/modules/post/post.service.ts index 773ef329..d4a49489 100644 --- a/src/modules/post/post.service.ts +++ b/src/modules/post/post.service.ts @@ -17,6 +17,7 @@ import { ErrorCodeEnum } from '~/constants/error-code.constant' import { EventBusEvents } from '~/constants/event-bus.constant' import { EventManagerService } from '~/processors/helper/helper.event.service' import { ImageService } from '~/processors/helper/helper.image.service' +import { TextMacroService } from '~/processors/helper/helper.macro.service' import { InjectModel } from '~/transformers/model.transformer' import { CategoryService } from '../category/category.service' @@ -35,6 +36,7 @@ export class PostService { private categoryService: CategoryService, private readonly imageService: ImageService, private readonly eventManager: EventManagerService, + private readonly textMacroService: TextMacroService, ) {} get model() { @@ -70,18 +72,30 @@ export class PostService { }) process.nextTick(async () => { - this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, { - scope: EventScope.TO_SYSTEM, - }) + const doc = res.toJSON() await Promise.all([ + this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, { + scope: EventScope.TO_SYSTEM, + }), this.eventManager.broadcast( BusinessEvents.POST_CREATE, { - ...res.toJSON(), + ...doc, category, }, { - scope: EventScope.TO_SYSTEM_VISITOR, + scope: EventScope.TO_SYSTEM, + }, + ), + this.eventManager.broadcast( + BusinessEvents.POST_CREATE, + { + ...doc, + category, + text: await this.textMacroService.replaceTextMacro(doc.text, doc), + }, + { + scope: EventScope.TO_SYSTEM, }, ), this.imageService.recordImageDimensions(this.postModel, res._id), @@ -131,20 +145,27 @@ export class PostService { Object.assign(originDocument, omit(data, PostModel.protectedKeys)) await originDocument.save() process.nextTick(async () => { - this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, { - scope: EventScope.TO_SYSTEM, - }) - + const doc = await this.postModel.findById(id).lean({ getters: true }) // 更新图片信息缓存 await Promise.all([ + this.eventManager.emit(EventBusEvents.CleanAggregateCache, null, { + scope: EventScope.TO_SYSTEM, + }), this.imageService.recordImageDimensions(this.postModel, id), - this.eventManager.broadcast( - BusinessEvents.POST_UPDATE, - await this.postModel.findById(id).lean({ getters: true }), - { - scope: EventScope.TO_SYSTEM_VISITOR, - }, - ), + doc && + this.eventManager.broadcast( + BusinessEvents.POST_UPDATE, + { + ...doc, + text: await this.textMacroService.replaceTextMacro(doc.text, doc), + }, + { + scope: EventScope.TO_VISITOR, + }, + ), + this.eventManager.broadcast(BusinessEvents.POST_UPDATE, doc, { + scope: EventScope.TO_SYSTEM, + }), ]) }) diff --git a/src/processors/helper/helper.macro.service.ts b/src/processors/helper/helper.macro.service.ts index c89621a9..185d658c 100644 --- a/src/processors/helper/helper.macro.service.ts +++ b/src/processors/helper/helper.macro.service.ts @@ -1,29 +1,25 @@ import dayjs from 'dayjs' -import { FastifyRequest } from 'fastify' -import { marked } from 'marked' -import { - BadRequestException, - Inject, - Injectable, - Logger, - Scope, -} from '@nestjs/common' -import { REQUEST } from '@nestjs/core' +import { BadRequestException, Injectable, Logger } from '@nestjs/common' -import { UserModel } from '~/modules/user/user.model' +import { ConfigsService } from '~/modules/configs/configs.service' import { deepCloneWithFunction } from '~/utils' import { safeEval } from '~/utils/safe-eval.util' -const logger = new Logger('TextMacroService') const RegMap = { '#': /^#(.*?)$/g, $: /^\$(.*?)$/g, '?': /^\?\??(.*?)\??\?$/g, } as const -class HelperStatic { - public ifConditionGrammar(text: string, model: T) { +@Injectable() +export class TextMacroService { + private readonly logger: Logger + constructor(private readonly configService: ConfigsService) { + this.logger = new Logger(TextMacroService.name) + } + + private ifConditionGrammar(text: string, model: T) { const conditionSplitter = text.split('|') conditionSplitter.forEach((item: string, index: string | number) => { conditionSplitter[index] = item.replace(/"/g, '') @@ -108,80 +104,77 @@ class HelperStatic { extraContext: Record = {}, ): Promise { + const { macros } = await this.configService.get('textOptions') + if (!macros) { + return text + } try { const matchedReg = /\[\[\s(.*?)\s\]\]/g - if (text.search(matchedReg) != -1) { - text = text.replace(matchedReg, (match, condition) => { - const ast = marked.lexer(text) - // FIXME: shallow find, if same text both in code block and paragraph, the macro in paragraph also will not replace - const isInCodeBlock = ast.some((i) => { - if (i.type === 'code' || i.type === 'codespan') { - return i.raw.includes(condition) - } - }) + const matched = text.search(matchedReg) != -1 - if (isInCodeBlock) { + if (!matched) { + return text + } + // const ast = marked.lexer(text) + + const cacheMap = {} as Record + + text = text.replace(matchedReg, (match, condition) => { + // FIXME: shallow find, if same text both in code block and paragraph, the macro in paragraph also will not replace + // const isInCodeBlock = ast.some((i) => { + // if (i.type === 'code' || i.type === 'codespan') { + // return i.raw.includes(condition) + // } + // }) + + // if (isInCodeBlock) { + // return match + // } + + condition = condition?.trim() + if (condition.search(RegMap['?']) != -1) { + return this.ifConditionGrammar(condition, model) + } + if (condition.search(RegMap['$']) != -1) { + const variable = condition + .replace(RegMap['$'], '$1') + .replace(/\s/g, '') + return model[variable] ?? extraContext[variable] + } + // eslint-disable-next-line no-useless-escape + if (condition.search(RegMap['#']) != -1) { + // eslint-disable-next-line no-useless-escape + const functions = condition.replace(RegMap['#'], '$1') + + if (typeof cacheMap[functions] != 'undefined') { + return cacheMap[functions] + } + + const variables = Object.keys(model).reduce( + (acc, key) => ({ [`$${key}`]: model[key], ...acc }), + {}, + ) + + try { + const result = safeEval( + `return ${functions}`, + this.generateFunctionContext({ ...variables, ...extraContext }), + + { timeout: 1000 }, + ) + cacheMap[functions] = result + return result + } catch { return match } + } + }) - condition = condition?.trim() - if (condition.search(RegMap['?']) != -1) { - return helper.ifConditionGrammar(condition, model) - } - if (condition.search(RegMap['$']) != -1) { - const variable = condition - .replace(RegMap['$'], '$1') - .replace(/\s/g, '') - return model[variable] ?? extraContext[variable] - } - // eslint-disable-next-line no-useless-escape - if (condition.search(RegMap['#']) != -1) { - // eslint-disable-next-line no-useless-escape - const functions = condition.replace(RegMap['#'], '$1') - - const variables = Object.keys(model).reduce( - (acc, key) => ({ [`$${key}`]: model[key], ...acc }), - {}, - ) - - try { - return safeEval( - `return ${functions}`, - this.generateFunctionContext({ ...variables, ...extraContext }), - { timeout: 1000 }, - ) - } catch { - return match - } - } - }) - } return text } catch (err) { - logger.log(err.message) + this.logger.log(err.message) return text } } } - -const helper = new HelperStatic() -@Injectable({ scope: Scope.REQUEST }) -export class TextMacroService { - constructor( - @Inject(REQUEST) - private readonly request: FastifyRequest & { - isMaster: boolean - user: UserModel - }, - ) {} - - public replaceTextMacro(text: string, model: object) { - const isMaster = this.request.isMaster - return helper.replaceTextMacro(text, model, { - hideForGuest(text: string) { - return isMaster ? text : '' - }, - }) - } -}