feat: 转换Markdown代码块为Argon结构
This commit is contained in:
parent
d9b3b9751a
commit
490239e827
@ -4,6 +4,13 @@ type MarkdownProcessor = {
|
||||
process: (value: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type HastNode = {
|
||||
children?: HastNode[];
|
||||
properties?: Record<string, unknown>;
|
||||
tagName?: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
type UnifiedModule = typeof import('unified');
|
||||
type RemarkParseModule = typeof import('remark-parse');
|
||||
type RemarkGfmModule = typeof import('remark-gfm');
|
||||
@ -22,9 +29,40 @@ const importEsm = new Function('specifier', 'return import(specifier)') as <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-)/;
|
||||
/^(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 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 = [
|
||||
'a',
|
||||
'blockquote',
|
||||
@ -38,6 +76,7 @@ const CONTENT_CLASS_TAG_NAMES = [
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'i',
|
||||
'li',
|
||||
'ol',
|
||||
'p',
|
||||
@ -199,6 +238,7 @@ export class MarkdownService {
|
||||
footnoteLabel: '脚注',
|
||||
})
|
||||
.use(rehypeRaw)
|
||||
.use(this.createArgonCodeblockPlugin())
|
||||
.use(rehypeSanitize, schema)
|
||||
.use(rehypeStringify) as MarkdownProcessor;
|
||||
}
|
||||
@ -264,21 +304,137 @@ export class MarkdownService {
|
||||
const schema = defaultSchema as Record<string, any>;
|
||||
const attributes = (schema.attributes || {}) as Record<string, any[]>;
|
||||
const classAttributes = Object.fromEntries(
|
||||
CONTENT_CLASS_TAG_NAMES.map((tagName) => [
|
||||
CONTENT_CLASS_TAG_NAMES.map((tagName) => {
|
||||
const tagAttributes = (attributes[tagName] || []).filter(
|
||||
(attribute) =>
|
||||
!Array.isArray(attribute) || attribute[0] !== 'className',
|
||||
);
|
||||
|
||||
return [
|
||||
tagName,
|
||||
[...(attributes[tagName] || []), CONTENT_CLASS_ATTRIBUTE],
|
||||
]),
|
||||
[...tagAttributes, ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE],
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...schema,
|
||||
clobberPrefix: '',
|
||||
tagNames: [...new Set([...(schema.tagNames || []), ...EXTRA_TAG_NAMES])],
|
||||
attributes: {
|
||||
...attributes,
|
||||
...classAttributes,
|
||||
a: [...(attributes.a || []), 'target', 'rel', CONTENT_CLASS_ATTRIBUTE],
|
||||
img: [...(attributes.img || []), 'loading', CONTENT_CLASS_ATTRIBUTE],
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
import { MarkdownService } from '../../src/common';
|
||||
|
||||
describe('MarkdownService', () => {
|
||||
@ -36,6 +38,107 @@ describe('MarkdownService', () => {
|
||||
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', () => {
|
||||
const schema = (service as any).createSanitizeSchema({
|
||||
attributes: {
|
||||
@ -45,20 +148,52 @@ describe('MarkdownService', () => {
|
||||
tagNames: ['p'],
|
||||
});
|
||||
|
||||
expect(schema.tagNames).toEqual(['p', 'figcaption', 'figure']);
|
||||
expect(schema.attributes.a).toEqual([
|
||||
expect(schema.tagNames).toEqual(
|
||||
expect.arrayContaining([
|
||||
'div',
|
||||
'figcaption',
|
||||
'figure',
|
||||
'i',
|
||||
'table',
|
||||
'tbody',
|
||||
'td',
|
||||
'th',
|
||||
'thead',
|
||||
'tr',
|
||||
]),
|
||||
);
|
||||
expect(schema.clobberPrefix).toBe('');
|
||||
expect(schema.attributes.a).toEqual(
|
||||
expect.arrayContaining([
|
||||
'href',
|
||||
'target',
|
||||
'rel',
|
||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
||||
]);
|
||||
expect(schema.attributes.figure).toEqual([
|
||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
||||
]);
|
||||
expect(schema.attributes.img).toEqual([
|
||||
'id',
|
||||
'dataLineNumber',
|
||||
'hljsCodeblockInner',
|
||||
'tooltip',
|
||||
]),
|
||||
);
|
||||
expect(schema.attributes.figure).toEqual(
|
||||
expect.arrayContaining(['id', 'dataLineNumber', 'hljsCodeblockInner']),
|
||||
);
|
||||
expect(schema.attributes.img).toEqual(
|
||||
expect.arrayContaining([
|
||||
'src',
|
||||
'loading',
|
||||
['className', /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/],
|
||||
]);
|
||||
'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