diff --git a/server/utils/changelog/markdown.ts b/server/utils/changelog/markdown.ts index aa11c19f88..abfb76bd4d 100644 --- a/server/utils/changelog/markdown.ts +++ b/server/utils/changelog/markdown.ts @@ -14,11 +14,11 @@ import { createMarkedHeadingExtension, renderToRawHtml, sanitizeRawHTML, + emojiText, } from '../mdKit' import { slugify } from '#shared/utils/html' import { Marked } from 'marked' import { hasProtocol, joinRelativeURL, parseFilename } from 'ufo' -import { convertToEmoji } from '#shared/utils/emoji' // cl = ChangeLog const clMarked = new Marked() @@ -33,6 +33,7 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { const renderer = new clMarked.Renderer({ gfm: true, }) + renderer.text = emojiText // GitHub-style callouts: > [!NOTE], > [!TIP], etc. renderer.blockquote = blockquote @@ -89,7 +90,7 @@ export async function changelogRenderer(mdRepoInfo: MarkdownRepoInfo) { const rawHtml = renderToRawHtml({ renderer, markdownBody, markedInstance: clMarked }) return { - html: sanitizeRawHTML(convertToEmoji(rawHtml), { + html: sanitizeRawHTML(rawHtml, { processImageUrl, processLink, toUserContentId, diff --git a/server/utils/mdKit.ts b/server/utils/mdKit.ts index 3ebe367d95..5845791628 100644 --- a/server/utils/mdKit.ts +++ b/server/utils/mdKit.ts @@ -1,5 +1,6 @@ import { type Tokens, + type Token, type RendererApi, type Renderer, type TokenizerObject, @@ -8,6 +9,7 @@ import { } from 'marked' import { highlightCodeSync } from './shiki' import { decodeHtmlEntities, stripHtmlTags, slugify } from '#shared/utils/html' +import { convertToEmoji } from '#shared/utils/emoji' import { escapeHtml } from './docs/text' import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' @@ -51,6 +53,12 @@ export const blockquote: RendererApi['blockquote'] = function ( return `
${body}\n` } +/** Convert emoji shortcodes only in Markdown text tokens. */ +export const emojiText: RendererApi['text'] = function (this: Renderer
][\s\S]*?<\/code>|][\s\S]*?<\/pre>)|(:[\w+-]+:)/gi,
- (match, codeBlock: string | undefined, shortcode: string | undefined) => {
- if (codeBlock) return codeBlock
- if (shortcode) {
- const key = shortcode.slice(1, -1)
- return emojis[key] ?? shortcode
- }
- return match
- },
- )
+export function convertToEmoji(text: string): string {
+ return text.replace(/:([\w+-]+):/g, (shortcode, key: string) => emojis[key] ?? shortcode)
}
diff --git a/test/unit/server/utils/changelog/markdown.spec.ts b/test/unit/server/utils/changelog/markdown.spec.ts
index 05302f3277..834fd1cf6d 100644
--- a/test/unit/server/utils/changelog/markdown.spec.ts
+++ b/test/unit/server/utils/changelog/markdown.spec.ts
@@ -36,6 +36,15 @@ function changelogMdInfoWithPath() {
}
}
+describe('Emoji rendering', () => {
+ it('converts shortcodes in text but not in inline code', async () => {
+ const renderer = await changelogRenderer(changelogMdinfo())
+ const result = renderer(':1234: `:1234:`')
+
+ expect(result.html).toContain('đ˘ :1234:
')
+ })
+})
+
describe('URL Resolution', () => {
describe('resolves from /markdown.md & releases', () => {
it('resolves relative .md links to blob URL for rendered viewing', async () => {
diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts
index d24158d777..674c5b0f41 100644
--- a/test/unit/server/utils/readme.spec.ts
+++ b/test/unit/server/utils/readme.spec.ts
@@ -33,6 +33,33 @@ function createRepoInfo(overrides?: Partial): RepositoryInfo {
}
}
+describe('Emoji rendering', () => {
+ it('converts shortcodes in text but not in code', async () => {
+ const markdown = ':1234: `:1234:` \\:1234:\n\n```text\n:1234:\n```'
+ const result = await renderReadmeHtml(markdown, 'test-pkg')
+
+ expect(result.html).toContain('đ˘ :1234: :1234:
')
+ expect(result.html).toContain(':1234:')
+ })
+
+ it('preserves shortcode-based heading anchors', async () => {
+ const result = await renderReadmeHtml('# :rocket: Install', 'test-pkg')
+
+ expect(result.html).toContain('id="user-content-rocket-install"')
+ expect(result.html).toContain('đ Install')
+ })
+
+ it('builds heading anchors from Markdown tokens before rendering emoji', async () => {
+ const markdown =
+ '# :rocket: [Install](https://example.com) `pkg&cli` now '
+ const result = await renderReadmeHtml(markdown, 'test-pkg')
+
+ expect(result.html).toContain('id="user-content-rocket-install-pkgcli-now"')
+ expect(result.html).toContain('đ')
+ expect(result.html).not.toContain('user-content-rocket-install-pkgcli-now-badge')
+ })
+})
+
describe('Playground Link Extraction', () => {
describe('StackBlitz', () => {
it('extracts stackblitz.com links', async () => {
diff --git a/test/unit/shared/utils/emoji.spec.ts b/test/unit/shared/utils/emoji.spec.ts
index 846860f1b9..22bfbd6f43 100644
--- a/test/unit/shared/utils/emoji.spec.ts
+++ b/test/unit/shared/utils/emoji.spec.ts
@@ -3,15 +3,10 @@ import { convertToEmoji } from '#shared/utils/emoji'
describe('convertToEmoji', () => {
it('converts :1234: to emoji', () => {
- expect(convertToEmoji(':1234:
')).toBe('đ˘
')
+ expect(convertToEmoji('Count :1234:')).toBe('Count đ˘')
})
- it('leaves :1234: in codeblocks untouched', () => {
- expect(convertToEmoji(':1234:')).toBe(':1234:')
- })
-
- it('converts :1234: to emoji outside of codeblock', () => {
- expect(convertToEmoji(':1234:
:1234:')).toBe('đ˘
:1234:')
- expect(convertToEmoji(':1234::1234:
')).toBe(':1234:đ˘
')
+ it('leaves unknown shortcodes untouched', () => {
+ expect(convertToEmoji(':not-an-emoji:')).toBe(':not-an-emoji:')
})
})