refactor: text macro
This commit is contained in:
@@ -8,5 +8,6 @@ export interface RSSProps {
|
||||
link: string
|
||||
title: string
|
||||
text: string
|
||||
id: string
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -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!,
|
||||
|
||||
@@ -257,3 +257,11 @@ export class FriendLinkOptionsDto {
|
||||
@JSONSchemaToggleField('允许申请友链')
|
||||
allowApply: boolean
|
||||
}
|
||||
|
||||
@JSONSchema({ title: '文本设定' })
|
||||
export class TextOptionsDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@JSONSchemaToggleField('开启文本宏替换')
|
||||
macros: boolean
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -74,6 +74,9 @@ const generateDefaultConfig: () => IConfig = () => ({
|
||||
terminalOptions: {
|
||||
enable: false,
|
||||
},
|
||||
textOptions: {
|
||||
macros: false,
|
||||
},
|
||||
})
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -49,8 +49,8 @@ export class FeedController {
|
||||
<title>${title}</title>
|
||||
<link>${xss(url)}</link>
|
||||
</image>
|
||||
${data
|
||||
.map((item) => {
|
||||
${await Promise.all(
|
||||
data.map(async (item) => {
|
||||
return `<entry>
|
||||
<title>${item.title}</title>
|
||||
<link href='${xss(item.link)}'/>
|
||||
@@ -61,7 +61,9 @@ export class FeedController {
|
||||
${`<blockquote>该渲染由 marked 生成, 可能存在部分语句不通或者排版问题, 最佳体验请前往: <a href='${xss(
|
||||
item.link,
|
||||
)}'>${xss(item.link)}</a></blockquote>
|
||||
${this.markdownService.renderMarkdownContent(item.text)}
|
||||
${await this.markdownService
|
||||
.renderArticle(item.id)
|
||||
.then((res) => res.html)}
|
||||
<p style='text-align: right'>
|
||||
<a href='${`${xss(item.link)}#comments`}'>看完了?说点什么呢</a>
|
||||
</p>`}
|
||||
@@ -69,8 +71,8 @@ export class FeedController {
|
||||
</content>
|
||||
</entry>
|
||||
`
|
||||
})
|
||||
.join('')}
|
||||
}),
|
||||
).then((res) => res.join(''))}
|
||||
</feed>`
|
||||
|
||||
return xml
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<NoteModel>,
|
||||
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,
|
||||
},
|
||||
)
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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<T extends object>(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<T extends object>(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<string, any> = {},
|
||||
): Promise<string> {
|
||||
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<string, any>
|
||||
|
||||
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 : ''
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user