feat: 转换Markdown代码块为Argon结构
This commit is contained in:
parent
d9b3b9751a
commit
490239e827
@ -4,6 +4,13 @@ type MarkdownProcessor = {
|
|||||||
process: (value: string) => Promise<unknown>;
|
process: (value: string) => Promise<unknown>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type HastNode = {
|
||||||
|
children?: HastNode[];
|
||||||
|
properties?: Record<string, unknown>;
|
||||||
|
tagName?: string;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type UnifiedModule = typeof import('unified');
|
type UnifiedModule = typeof import('unified');
|
||||||
type RemarkParseModule = typeof import('remark-parse');
|
type RemarkParseModule = typeof import('remark-parse');
|
||||||
type RemarkGfmModule = typeof import('remark-gfm');
|
type RemarkGfmModule = typeof import('remark-gfm');
|
||||||
@ -22,9 +29,40 @@ const importEsm = new Function('specifier', 'return import(specifier)') as <T>(
|
|||||||
const MARKDOWN_SOURCE_PATTERN =
|
const MARKDOWN_SOURCE_PATTERN =
|
||||||
/<!--\s*kt-markdown-source:([A-Za-z0-9+/=]+)\s*-->/;
|
/<!--\s*kt-markdown-source:([A-Za-z0-9+/=]+)\s*-->/;
|
||||||
const CONTENT_CLASS_PATTERN =
|
const CONTENT_CLASS_PATTERN =
|
||||||
/^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/;
|
/^(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_CLASS_ATTRIBUTE = ['className', CONTENT_CLASS_PATTERN];
|
||||||
const EXTRA_TAG_NAMES = ['figcaption', 'figure'];
|
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 = [
|
const CONTENT_CLASS_TAG_NAMES = [
|
||||||
'a',
|
'a',
|
||||||
'blockquote',
|
'blockquote',
|
||||||
@ -38,6 +76,7 @@ const CONTENT_CLASS_TAG_NAMES = [
|
|||||||
'h4',
|
'h4',
|
||||||
'h5',
|
'h5',
|
||||||
'h6',
|
'h6',
|
||||||
|
'i',
|
||||||
'li',
|
'li',
|
||||||
'ol',
|
'ol',
|
||||||
'p',
|
'p',
|
||||||
@ -199,6 +238,7 @@ export class MarkdownService {
|
|||||||
footnoteLabel: '脚注',
|
footnoteLabel: '脚注',
|
||||||
})
|
})
|
||||||
.use(rehypeRaw)
|
.use(rehypeRaw)
|
||||||
|
.use(this.createArgonCodeblockPlugin())
|
||||||
.use(rehypeSanitize, schema)
|
.use(rehypeSanitize, schema)
|
||||||
.use(rehypeStringify) as MarkdownProcessor;
|
.use(rehypeStringify) as MarkdownProcessor;
|
||||||
}
|
}
|
||||||
@ -264,21 +304,137 @@ export class MarkdownService {
|
|||||||
const schema = defaultSchema as Record<string, any>;
|
const schema = defaultSchema as Record<string, any>;
|
||||||
const attributes = (schema.attributes || {}) as Record<string, any[]>;
|
const attributes = (schema.attributes || {}) as Record<string, any[]>;
|
||||||
const classAttributes = Object.fromEntries(
|
const classAttributes = Object.fromEntries(
|
||||||
CONTENT_CLASS_TAG_NAMES.map((tagName) => [
|
CONTENT_CLASS_TAG_NAMES.map((tagName) => {
|
||||||
tagName,
|
const tagAttributes = (attributes[tagName] || []).filter(
|
||||||
[...(attributes[tagName] || []), CONTENT_CLASS_ATTRIBUTE],
|
(attribute) =>
|
||||||
]),
|
!Array.isArray(attribute) || attribute[0] !== 'className',
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
tagName,
|
||||||
|
[...tagAttributes, ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE],
|
||||||
|
];
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...schema,
|
...schema,
|
||||||
|
clobberPrefix: '',
|
||||||
tagNames: [...new Set([...(schema.tagNames || []), ...EXTRA_TAG_NAMES])],
|
tagNames: [...new Set([...(schema.tagNames || []), ...EXTRA_TAG_NAMES])],
|
||||||
attributes: {
|
attributes: {
|
||||||
...attributes,
|
...attributes,
|
||||||
...classAttributes,
|
...classAttributes,
|
||||||
a: [...(attributes.a || []), 'target', 'rel', CONTENT_CLASS_ATTRIBUTE],
|
a: [
|
||||||
img: [...(attributes.img || []), 'loading', CONTENT_CLASS_ATTRIBUTE],
|
...(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 [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
|
||||||
import { MarkdownService } from '../../src/common';
|
import { MarkdownService } from '../../src/common';
|
||||||
|
|
||||||
describe('MarkdownService', () => {
|
describe('MarkdownService', () => {
|
||||||
@ -36,6 +38,107 @@ describe('MarkdownService', () => {
|
|||||||
expect(html).not.toContain('<script>');
|
expect(html).not.toContain('<script>');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sanitizes real Argon codeblock html while preserving runtime classes', async () => {
|
||||||
|
const argonHtml =
|
||||||
|
'<pre class="wp-block-code hljs-codeblock" id="demo" onclick="alert(1)"><code class="hljs sql"><table class="hljs-ln"><tbody><tr><td class="hljs-ln-line hljs-ln-numbers" data-line-number="1">1</td><td class="hljs-ln-line hljs-ln-code">select 1;</td></tr></tbody></table></code><div class="hljs-control"><i class="fa fa-copy" tooltip="复制"></i></div></pre><script>alert(1)</script>';
|
||||||
|
const script = `
|
||||||
|
const { MarkdownService } = require('./src/common');
|
||||||
|
(async () => {
|
||||||
|
const html = await new MarkdownService().sanitizeHtml(${JSON.stringify(argonHtml)});
|
||||||
|
process.stdout.write(JSON.stringify(html));
|
||||||
|
})().catch((error) => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const output = execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--experimental-vm-modules',
|
||||||
|
'-r',
|
||||||
|
'ts-node/register',
|
||||||
|
'-r',
|
||||||
|
'tsconfig-paths/register',
|
||||||
|
'-e',
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TS_NODE_TRANSPILE_ONLY: 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const html = JSON.parse(output) as string;
|
||||||
|
|
||||||
|
expect(html).toContain('class="wp-block-code hljs-codeblock"');
|
||||||
|
expect(html).toContain('id="demo"');
|
||||||
|
expect(html).toContain('<table class="hljs-ln">');
|
||||||
|
expect(html).toContain('data-line-number="1"');
|
||||||
|
expect(html).toContain('class="fa fa-copy"');
|
||||||
|
expect(html).toContain('tooltip="复制"');
|
||||||
|
expect(html).not.toContain('onclick');
|
||||||
|
expect(html).not.toContain('<script>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders markdown fenced code blocks with Argon codeblock structure', async () => {
|
||||||
|
const cases = {
|
||||||
|
bash: '```bash\npwd\n```',
|
||||||
|
js: '```js\nconst value = 1;\n```',
|
||||||
|
plaintext: '```\nplain\n```',
|
||||||
|
sh: '```sh\necho ok\n```',
|
||||||
|
ts: '```ts\nconst component = <Kwi>demo</Kwi>;\n```',
|
||||||
|
};
|
||||||
|
const script = `
|
||||||
|
const { MarkdownService } = require('./src/common');
|
||||||
|
(async () => {
|
||||||
|
const service = new MarkdownService();
|
||||||
|
const cases = ${JSON.stringify(cases)};
|
||||||
|
const entries = await Promise.all(
|
||||||
|
Object.entries(cases).map(async ([name, markdown]) => [
|
||||||
|
name,
|
||||||
|
await service.renderToHtml(markdown),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
process.stdout.write(JSON.stringify(Object.fromEntries(entries)));
|
||||||
|
})().catch((error) => {
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
const output = execFileSync(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
'--experimental-vm-modules',
|
||||||
|
'-r',
|
||||||
|
'ts-node/register',
|
||||||
|
'-r',
|
||||||
|
'tsconfig-paths/register',
|
||||||
|
'-e',
|
||||||
|
script,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: 'utf8',
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TS_NODE_TRANSPILE_ONLY: 'true',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const html = JSON.parse(output) as Record<string, string>;
|
||||||
|
|
||||||
|
expect(html.ts).toContain(
|
||||||
|
'<pre class="wp-block-code hljs-codeblock"><code class="hljs typescript">',
|
||||||
|
);
|
||||||
|
expect(html.ts).toContain('<Kwi>demo</Kwi>');
|
||||||
|
expect(html.ts).not.toContain('<Kwi>');
|
||||||
|
expect(html.js).toContain('<code class="hljs javascript">');
|
||||||
|
expect(html.bash).toContain('<code class="hljs shell">');
|
||||||
|
expect(html.sh).toContain('<code class="hljs shell">');
|
||||||
|
expect(html.plaintext).toContain('<code class="hljs plaintext">');
|
||||||
|
});
|
||||||
|
|
||||||
it('builds sanitizer schema for content classes and image loading', () => {
|
it('builds sanitizer schema for content classes and image loading', () => {
|
||||||
const schema = (service as any).createSanitizeSchema({
|
const schema = (service as any).createSanitizeSchema({
|
||||||
attributes: {
|
attributes: {
|
||||||
@ -45,20 +148,52 @@ describe('MarkdownService', () => {
|
|||||||
tagNames: ['p'],
|
tagNames: ['p'],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(schema.tagNames).toEqual(['p', 'figcaption', 'figure']);
|
expect(schema.tagNames).toEqual(
|
||||||
expect(schema.attributes.a).toEqual([
|
expect.arrayContaining([
|
||||||
'href',
|
'div',
|
||||||
'target',
|
'figcaption',
|
||||||
'rel',
|
'figure',
|
||||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
'i',
|
||||||
]);
|
'table',
|
||||||
expect(schema.attributes.figure).toEqual([
|
'tbody',
|
||||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
'td',
|
||||||
]);
|
'th',
|
||||||
expect(schema.attributes.img).toEqual([
|
'thead',
|
||||||
'src',
|
'tr',
|
||||||
'loading',
|
]),
|
||||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
);
|
||||||
]);
|
expect(schema.clobberPrefix).toBe('');
|
||||||
|
expect(schema.attributes.a).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'href',
|
||||||
|
'target',
|
||||||
|
'rel',
|
||||||
|
'id',
|
||||||
|
'dataLineNumber',
|
||||||
|
'hljsCodeblockInner',
|
||||||
|
'tooltip',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(schema.attributes.figure).toEqual(
|
||||||
|
expect.arrayContaining(['id', 'dataLineNumber', 'hljsCodeblockInner']),
|
||||||
|
);
|
||||||
|
expect(schema.attributes.img).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'src',
|
||||||
|
'loading',
|
||||||
|
'id',
|
||||||
|
'dataLineNumber',
|
||||||
|
'hljsCodeblockInner',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
const classAttribute = schema.attributes.a.find((item) =>
|
||||||
|
Array.isArray(item),
|
||||||
|
);
|
||||||
|
expect(classAttribute).toBeDefined();
|
||||||
|
const classPattern = (classAttribute as [string, RegExp])[1];
|
||||||
|
expect(classPattern.test('hljs-codeblock')).toBe(true);
|
||||||
|
expect(classPattern.test('fa-clipboard')).toBe(true);
|
||||||
|
expect(classPattern.test('fancybox-wrapper')).toBe(true);
|
||||||
|
expect(classPattern.test('sql')).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user