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 {
|
import {
|
||||||
createUniqueHeadingId,
|
createUniqueHeadingId,
|
||||||
replaceGithubAdmonitions,
|
replaceGithubAdmonitions,
|
||||||
|
replaceHugoAdmonitions,
|
||||||
|
resolveAdmonitionTitle,
|
||||||
sanitizeMarkdownHtml
|
sanitizeMarkdownHtml
|
||||||
} from '$lib/markdown/rendering';
|
} 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>'
|
'<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 typeLower = type.toLowerCase();
|
||||||
const displayTitle = escapeHtml(
|
const displayTitle = escapeHtml(resolveAdmonitionTitle(type, rawTitle));
|
||||||
title ? title : type.charAt(0).toUpperCase() + type.slice(1).toLowerCase()
|
|
||||||
);
|
|
||||||
const icon = icons[typeLower as keyof typeof icons] || icons.note;
|
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`;
|
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 =
|
let html = replaceGithubAdmonitions(markdown, renderAdmonition);
|
||||||
/\{\{<\s*admonition\s+([a-zA-Z]+)(?:\s+"(.*?)")?\s*>\}\}\r?\n([\s\S]*?)\{\{<\s*\/admonition\s*>\}\}/gi;
|
html = replaceHugoAdmonitions(html, renderAdmonition);
|
||||||
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`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
createHeadingId,
|
createHeadingId,
|
||||||
createUniqueHeadingId,
|
createUniqueHeadingId,
|
||||||
replaceGithubAdmonitions,
|
replaceGithubAdmonitions,
|
||||||
|
replaceHugoAdmonitions,
|
||||||
|
resolveAdmonitionTitle,
|
||||||
sanitizeMarkdownHtml
|
sanitizeMarkdownHtml
|
||||||
} from './rendering';
|
} 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', () => {
|
describe('heading IDs', () => {
|
||||||
it('keeps Unicode letters and provides a deterministic fallback', () => {
|
it('keeps Unicode letters and provides a deterministic fallback', () => {
|
||||||
expect(createHeadingId('中文 标题')).toBe('中文-标题');
|
expect(createHeadingId('中文 标题')).toBe('中文-标题');
|
||||||
|
|||||||
@@ -2,6 +2,46 @@ import sanitizeHtml from 'sanitize-html';
|
|||||||
|
|
||||||
type AdmonitionRenderer = (type: string, title: string, content: string) => string;
|
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 {
|
export function replaceGithubAdmonitions(markdown: string, render: AdmonitionRenderer): string {
|
||||||
const pattern =
|
const pattern =
|
||||||
/(?:^|\r?\n)> ?\[!(NOTE|ABSTRACT|INFO|TIP|SUCCESS|QUESTION|WARNING|FAILURE|DANGER|BUG|EXAMPLE|QUOTE|IMPORTANT|CAUTION)\][ \t]*(.*?)\r?\n((?:>.*(?:\r?\n|$))*)/gi;
|
/(?:^|\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 {
|
export function createHeadingId(rawText: string): string {
|
||||||
const slug = rawText
|
const slug = rawText
|
||||||
.replace(/<[^>]*>?/g, '')
|
.replace(/<[^>]*>?/g, '')
|
||||||
|
|||||||
Reference in new issue
Block a user