feat: 转换Markdown代码块为Argon结构

This commit is contained in:
sunlei 2026-07-01 16:42:49 +08:00
parent d9b3b9751a
commit 490239e827
2 changed files with 314 additions and 23 deletions

View File

@ -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 [];
}
}

View File

@ -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('&#x3C;Kwi>demo&#x3C;/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);
});
});