import { Injectable } from '@nestjs/common'; type MarkdownProcessor = { process: (value: string) => Promise; }; type HastNode = { children?: HastNode[]; properties?: Record; tagName?: string; type?: string; }; 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 ( specifier: string, ) => Promise; const MARKDOWN_SOURCE_PATTERN = //; const CONTENT_CLASS_PATTERN = /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-|hljs(?:-|$)|fa(?:-|$)|fancybox-|lazyload(?:-|$)|collapse-block|bash$|coffeescript$|css$|dockerfile$|html$|java$|javascript$|json$|markdown$|md$|nginx$|php$|plaintext$|python$|scss$|shell$|sql$|text$|typescript$|xml$|yaml$|yml$)/; const CONTENT_CLASS_ATTRIBUTE = ['className', CONTENT_CLASS_PATTERN]; const CONTENT_HTML_ATTRIBUTES = [ 'dataLineNumber', 'data-line-number', 'hljsCodeblockInner', 'hljs-codeblock-inner', 'id', 'tooltip', 'tooltipDisableBreakline', 'tooltip-disable-breakline', 'tooltipEnableBreakline', 'tooltip-enable-breakline', 'tooltipExitFullscreen', 'tooltip-exit-fullscreen', 'tooltipFullscreen', 'tooltip-fullscreen', 'tooltipHideLinenumber', 'tooltip-hide-linenumber', 'tooltipShowLinenumber', 'tooltip-show-linenumber', ]; const EXTRA_TAG_NAMES = [ 'div', 'figcaption', 'figure', 'i', 'table', 'tbody', 'td', 'th', 'thead', 'tr', ]; const CONTENT_CLASS_TAG_NAMES = [ 'a', 'blockquote', 'code', 'div', 'figcaption', 'figure', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'i', 'li', 'ol', 'p', 'pre', 'span', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'ul', ]; @Injectable() export class MarkdownService { private processorPromise?: Promise; private htmlToMarkdownProcessorPromise?: Promise; private sanitizeHtmlProcessorPromise?: Promise; /** * 渲染 当前模块输出。 * @param markdown - markdown 输入;驱动 `processor.process()` 的 公共基础设施步骤。 */ async renderToHtml(markdown?: string | null) { const processor = await this.getProcessor(); const file = await processor.process(markdown || ''); return `${file}`; } /** * 渲染 当前模块输出。 * @param html - html 输入;驱动 `processor.process()` 的 公共基础设施步骤。 */ async renderHtmlToMarkdown(html?: string | null) { const processor = await this.getHtmlToMarkdownProcessor(); const file = await processor.process(this.stripSourceMarker(html || '')); return `${file}`.trim(); } /** * 执行 当前模块流程。 * @param html - html 输入;驱动 `processor.process()` 的 公共基础设施步骤。 */ async sanitizeHtml(html?: string | null) { const processor = await this.getSanitizeHtmlProcessor(); const file = await processor.process(this.stripSourceMarker(html || '')); return `${file}`.trim(); } /** * 执行 当前模块流程。 * @param html - html 输入;驱动 `this.stripSourceMarker()` 的 公共基础设施步骤。 * @param markdown - markdown 输入;影响 embedSourceHtml 的返回值。 */ embedSourceHtml(html: string, markdown?: string | null) { const source = markdown || ''; if (!source) return this.stripSourceMarker(html); return `${this.stripSourceMarker(html)}\n`; } /** * 执行 当前模块流程。 * @param value - 待转换值;影响 extractSource 的返回值。 */ 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 ''; } } /** * 执行 当前模块流程。 * @param value - 待转换值;影响 stripSourceMarker 的返回值。 */ 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; } /** * 创建 当前模块对象或配置。 * @returns 创建后的 当前模块对象或配置。 */ private async createProcessor(): Promise { const [ { unified }, { default: remarkParse }, { default: remarkGfm }, { default: remarkRehype }, { default: rehypeRaw }, { default: rehypeSanitize, defaultSchema }, { default: rehypeStringify }, ] = await Promise.all([ importEsm('unified'), importEsm('remark-parse'), importEsm('remark-gfm'), importEsm('remark-rehype'), importEsm('rehype-raw'), importEsm('rehype-sanitize'), importEsm('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(this.createArgonCodeblockPlugin()) .use(rehypeSanitize, schema) .use(rehypeStringify) as MarkdownProcessor; } /** * 创建 当前模块对象或配置。 * @returns 创建后的 当前模块对象或配置。 */ private async createHtmlToMarkdownProcessor(): Promise { const [ { unified }, { default: rehypeParse }, { default: rehypeRemark }, { default: remarkGfm }, { default: remarkStringify }, ] = await Promise.all([ importEsm('unified'), importEsm('rehype-parse'), importEsm('rehype-remark'), importEsm('remark-gfm'), importEsm('remark-stringify'), ]); return unified() .use(rehypeParse, { fragment: true }) .use(rehypeRemark) .use(remarkGfm) .use(remarkStringify, { bullet: '-', fences: true, listItemIndent: 'one', }) as MarkdownProcessor; } /** * 创建 当前模块对象或配置。 * @returns 创建后的 当前模块对象或配置。 */ private async createSanitizeHtmlProcessor(): Promise { const [ { unified }, { default: rehypeParse }, { default: rehypeSanitize, defaultSchema }, { default: rehypeStringify }, ] = await Promise.all([ importEsm('unified'), importEsm('rehype-parse'), importEsm('rehype-sanitize'), importEsm('rehype-stringify'), ]); return unified() .use(rehypeParse, { fragment: true }) .use(rehypeSanitize, this.createSanitizeSchema(defaultSchema)) .use(rehypeStringify) as MarkdownProcessor; } /** * 创建 当前模块对象或配置。 * @param defaultSchema - defaultSchema 输入;生成 公共基础设施对象。 */ private createSanitizeSchema(defaultSchema: unknown) { const schema = defaultSchema as Record; const attributes = (schema.attributes || {}) as Record; const classAttributes = Object.fromEntries( CONTENT_CLASS_TAG_NAMES.map((tagName) => { const tagAttributes = (attributes[tagName] || []).filter( (attribute) => !Array.isArray(attribute) || attribute[0] !== 'className', ); return [ tagName, [...tagAttributes, ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE], ]; }), ); return { ...schema, clobberPrefix: '', tagNames: [...new Set([...(schema.tagNames || []), ...EXTRA_TAG_NAMES])], attributes: { ...attributes, ...classAttributes, a: [ ...(attributes.a || []).filter( (attribute) => !Array.isArray(attribute) || attribute[0] !== 'className', ), 'target', 'rel', ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE, ], img: [ ...(attributes.img || []).filter( (attribute) => !Array.isArray(attribute) || attribute[0] !== 'className', ), 'loading', ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE, ], }, }; } /** * 创建 Markdown code fence 到 Argon 代码块 DOM 的 rehype 转换插件。 * @returns rehype transformer;会原地补齐 pre/code class,最终仍由 sanitizer 过滤。 */ private createArgonCodeblockPlugin() { return () => (tree: HastNode) => { this.visitHast(tree, (node) => { if (node.type !== 'element' || node.tagName !== 'pre') return; const codeNode = node.children?.find( (child) => child.type === 'element' && child.tagName === 'code', ); if (!codeNode) return; const language = this.normalizeCodeLanguage( codeNode.properties?.className, ); const codeClassNames = this.toClassList( codeNode.properties?.className, ).filter((className) => !className.startsWith('language-')); node.properties = { ...node.properties, className: this.mergeClassNames(node.properties?.className, [ 'wp-block-code', 'hljs-codeblock', ]), }; codeNode.properties = { ...codeNode.properties, className: this.mergeClassNames(codeClassNames, ['hljs', language]), }; }); }; } /** * 深度遍历 HAST 节点树,供无额外依赖的小型 rehype 转换使用。 * @param node - 当前 HAST 节点;缺少 children 时只访问当前节点。 * @param visitor - 对每个节点执行的同步访问函数。 */ private visitHast(node: HastNode, visitor: (node: HastNode) => void) { visitor(node); node.children?.forEach((child) => this.visitHast(child, visitor)); } /** * 将 remark-rehype 生成的 language-* class 规范化为 Blog Argon 使用的 hljs 语言名。 * @param className - HAST className 属性,可能是数组、字符串或空值。 * @returns sanitizer 白名单允许的标准语言名;无语言时返回 plaintext。 */ private normalizeCodeLanguage(className: unknown) { const language = this.toClassList(className) .find((className) => className.startsWith('language-')) ?.replace(/^language-/, '') .toLowerCase(); if (!language) return 'plaintext'; if (language === 'ts') return 'typescript'; if (language === 'js') return 'javascript'; if (language === 'bash' || language === 'sh') return 'shell'; return language; } /** * 合并 className 属性并保持必需 class 在前,避免重复 class 影响 Argon 选择器。 * @param current - 现有 className 属性或 class 数组。 * @param required - 当前转换必须注入的 Argon/hljs class。 * @returns 去重后的 className 数组,交给 rehype-stringify 输出。 */ private mergeClassNames(current: unknown, required: string[]) { return [...new Set([...required, ...this.toClassList(current)])]; } /** * 将 HAST className 属性转换成可安全合并的字符串数组。 * @param value - HAST className 属性,支持字符串、字符串数组和空值。 * @returns 过滤空白后的 class 名列表。 */ private toClassList(value: unknown) { if (Array.isArray(value)) { return value.flatMap((item) => this.toClassList(item)); } if (typeof value === 'string') { return value.split(/\s+/).filter(Boolean); } return []; } }