diff --git a/src/lib/components/MarkdownRenderer.svelte b/src/lib/components/MarkdownRenderer.svelte index b40b616..95ec592 100644 --- a/src/lib/components/MarkdownRenderer.svelte +++ b/src/lib/components/MarkdownRenderer.svelte @@ -7,6 +7,8 @@ import { createUniqueHeadingId, replaceGithubAdmonitions, + replaceHugoAdmonitions, + resolveAdmonitionTitle, sanitizeMarkdownHtml } from '$lib/markdown/rendering'; @@ -337,25 +339,15 @@ '' }; - 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
\n
${icon}${displayTitle}
\n
\n
\n\n${content}\n\n
\n
\n
\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
\n
${icon}${displayTitle}
\n
\n
\n\n${content}\n\n
\n
\n
\n`; - }); + let html = replaceGithubAdmonitions(markdown, renderAdmonition); + html = replaceHugoAdmonitions(html, renderAdmonition); return html; } diff --git a/src/lib/markdown/rendering.test.ts b/src/lib/markdown/rendering.test.ts index b147fb2..6c8e703 100644 --- a/src/lib/markdown/rendering.test.ts +++ b/src/lib/markdown/rendering.test.ts @@ -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) => + ``; + + 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(''); + expect(unquoted).toContain(''); + expect(missing).toContain(''); + }); + + it('supports single quotes and multi-word bare titles', () => { + const singleQuoted = replaceHugoAdmonitions( + "{{< admonition tip 'Best practice' >}}\nbody\n{{< /admonition >}}", + render + ); + expect(singleQuoted).toContain(''); + }); +}); + describe('heading IDs', () => { it('keeps Unicode letters and provides a deterministic fallback', () => { expect(createHeadingId('中文 标题')).toBe('中文-标题'); diff --git a/src/lib/markdown/rendering.ts b/src/lib/markdown/rendering.ts index d9f086d..ddd6d0a 100644 --- a/src/lib/markdown/rendering.ts +++ b/src/lib/markdown/rendering.ts @@ -2,6 +2,46 @@ import sanitizeHtml from 'sanitize-html'; type AdmonitionRenderer = (type: string, title: string, content: string) => string; +/** 无标题时的默认标题,与编辑器菜单中的中文名称保持一致 */ +export const DEFAULT_ADMONITION_TITLES: Record = { + 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 [标题] >}} ... {{< /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, '')