refactor: 提取 Hugo admonition 解析与标题处理逻辑并增强兼容性
continuous-integration/drone/push Build is passing

This commit is contained in:
seahi committed 2026-09-16 20:38:13 +08:00
1 parent 2820894e1c
commit 7574b928d6
3 files changed
+109 -15

No files matched your search

+7 -15
View File
@@ -7,6 +7,8 @@
import {
createUniqueHeadingId,
replaceGithubAdmonitions,
replaceHugoAdmonitions,
resolveAdmonitionTitle,
sanitizeMarkdownHtml
} from '$lib/markdown/rendering';
@@ -337,25 +339,15 @@
'<svg viewBox="0 0 512 512" fill="currentColor" height="1em" width="1em"><path d="M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"></path></svg>'
};
let html = replaceGithubAdmonitions(markdown, (type, title, content) => {
function renderAdmonition(type: string, rawTitle: string, content: string) {
const typeLower = type.toLowerCase();
const displayTitle = escapeHtml(
title ? title : type.charAt(0).toUpperCase() + type.slice(1).toLowerCase()
);
const displayTitle = escapeHtml(resolveAdmonitionTitle(type, rawTitle));
const icon = icons[typeLower as keyof typeof icons] || icons.note;
return `\n<div class="details admonition ${typeLower} open">\n<div class="details-summary admonition-title"><span class="icon">${icon}</span><span>${displayTitle}</span></div>\n<div class="details-content">\n<div class="admonition-content">\n\n${content}\n\n</div>\n</div>\n</div>\n`;
});
}
const hugoRegex =
/\{\{<\s*admonition\s+([a-zA-Z]+)(?:\s+"(.*?)")?\s*>\}\}\r?\n([\s\S]*?)\{\{<\s*\/admonition\s*>\}\}/gi;
html = html.replace(hugoRegex, (match, type, title, content) => {
const typeLower = type.toLowerCase();
const displayTitle = escapeHtml(
title ? title : type.charAt(0).toUpperCase() + type.slice(1).toLowerCase()
);
const icon = icons[typeLower as keyof typeof icons] || icons.note;
return `\n<div class="details admonition ${typeLower} open">\n<div class="details-summary admonition-title"><span class="icon">${icon}</span><span>${displayTitle}</span></div>\n<div class="details-content">\n<div class="admonition-content">\n\n${content}\n\n</div>\n</div>\n</div>\n`;
});
let html = replaceGithubAdmonitions(markdown, renderAdmonition);
html = replaceHugoAdmonitions(html, renderAdmonition);
return html;
}
+48
View File
@@ -3,6 +3,8 @@ import {
createHeadingId,
createUniqueHeadingId,
replaceGithubAdmonitions,
replaceHugoAdmonitions,
resolveAdmonitionTitle,
sanitizeMarkdownHtml
} from './rendering';
@@ -21,6 +23,52 @@ describe('replaceGithubAdmonitions', () => {
});
});
describe('resolveAdmonitionTitle', () => {
it('strips matching quotes and trims bare titles', () => {
expect(resolveAdmonitionTitle('note', '"自定义标题"')).toBe('自定义标题');
expect(resolveAdmonitionTitle('note', "'自定义标题'")).toBe('自定义标题');
expect(resolveAdmonitionTitle('note', ' 裸标题 ')).toBe('裸标题');
});
it('falls back to the localized default title, then the capitalized type', () => {
expect(resolveAdmonitionTitle('note', '')).toBe('注意');
expect(resolveAdmonitionTitle('ABSTRACT', ' ')).toBe('摘要');
expect(resolveAdmonitionTitle('unknown', '')).toBe('Unknown');
});
});
describe('replaceHugoAdmonitions', () => {
const render = (type: string, title: string, content: string) =>
`<aside data-type="${type}">${resolveAdmonitionTitle(type, title)}:${content.trim()}</aside>`;
it('accepts quoted, unquoted and missing titles', () => {
const quoted = replaceHugoAdmonitions(
'{{< admonition abstract "摘要" >}}\nbody\n{{< /admonition >}}',
render
);
const unquoted = replaceHugoAdmonitions(
'{{< admonition abstract 摘要>}}\nbody\n{{< /admonition >}}',
render
);
const missing = replaceHugoAdmonitions(
'{{< admonition abstract >}}\nbody\n{{< /admonition >}}',
render
);
expect(quoted).toContain('<aside data-type="abstract">摘要:body</aside>');
expect(unquoted).toContain('<aside data-type="abstract">摘要:body</aside>');
expect(missing).toContain('<aside data-type="abstract">摘要:body</aside>');
});
it('supports single quotes and multi-word bare titles', () => {
const singleQuoted = replaceHugoAdmonitions(
"{{< admonition tip 'Best practice' >}}\nbody\n{{< /admonition >}}",
render
);
expect(singleQuoted).toContain('<aside data-type="tip">Best practice:body</aside>');
});
});
describe('heading IDs', () => {
it('keeps Unicode letters and provides a deterministic fallback', () => {
expect(createHeadingId('中文 标题')).toBe('中文-标题');
+54
View File
@@ -2,6 +2,46 @@ import sanitizeHtml from 'sanitize-html';
type AdmonitionRenderer = (type: string, title: string, content: string) => string;
/** 无标题时的默认标题,与编辑器菜单中的中文名称保持一致 */
export const DEFAULT_ADMONITION_TITLES: Record<string, string> = {
note: '注意',
abstract: '摘要',
info: '信息',
tip: '技巧',
success: '成功',
question: '问题',
warning: '警告',
failure: '失败',
danger: '危险',
bug: 'Bug',
example: '示例',
quote: '引用',
important: '重要',
caution: '谨慎'
};
/**
* 解析 admonition 标题:
* - 兼容 `"标题"`、`'标题'` 以及不带引号的裸标题(如 `abstract 摘要`)
* - 未提供标题时回退到该类型的默认标题,再回退到首字母大写的类型名
*/
export function resolveAdmonitionTitle(type: string, rawTitle: string): string {
const title = (rawTitle ?? '')
.trim()
.replace(/^(["'])([\s\S]*)\1$/, '$2')
.trim();
if (title) {
return title;
}
const typeLower = type.toLowerCase();
return (
DEFAULT_ADMONITION_TITLES[typeLower] ||
type.charAt(0).toUpperCase() + type.slice(1).toLowerCase()
);
}
export function replaceGithubAdmonitions(markdown: string, render: AdmonitionRenderer): string {
const pattern =
/(?:^|\r?\n)> ?\[!(NOTE|ABSTRACT|INFO|TIP|SUCCESS|QUESTION|WARNING|FAILURE|DANGER|BUG|EXAMPLE|QUOTE|IMPORTANT|CAUTION)\][ \t]*(.*?)\r?\n((?:>.*(?:\r?\n|$))*)/gi;
@@ -11,6 +51,20 @@ export function replaceGithubAdmonitions(markdown: string, render: AdmonitionRen
);
}
/**
* 替换 Hugo/Docsy 风格的 admonition shortcode:
* `{{< admonition <type> [标题] >}} ... {{< /admonition >}}`
* 标题可选,且可以是 `"标题"`、`'标题'` 或裸文本。
*/
export function replaceHugoAdmonitions(html: string, render: AdmonitionRenderer): string {
const pattern =
/\{\{<\s*admonition\s+([a-zA-Z][\w-]*)([^>]*?)>\}\}\r?\n([\s\S]*?)\{\{<\s*\/admonition\s*>\}\}/gi;
return html.replace(pattern, (_match, type: string, rawTitle: string, content: string) =>
render(type, rawTitle.trim(), content)
);
}
export function createHeadingId(rawText: string): string {
const slug = rawText
.replace(/<[^>]*>?/g, '')