This commit is contained in:
1 parent
2820894e1c
commit
7574b928d6
3 files changed
+109
-15
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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('中文-标题');
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
Reference in new issue
Block a user