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, token) { + if (token.type === 'escape') return token.text + return token.tokens ? this.parser.parseInline(token.tokens) : convertToEmoji(token.text) +} + /** * created code highlighter with Shiki for Marked */ @@ -214,6 +222,20 @@ export function getHeadingSlugSource(text: string): string { return stripHtmlTags(text).trim() } +function getHeadingSlugSourceFromTokens(tokens: Token[]): string { + return tokens + .map(token => { + if (token.type === 'image' || token.type === 'br') return '' + if (token.type === 'codespan') return escapeHtml(token.text) + if ('tokens' in token && token.tokens) return getHeadingSlugSourceFromTokens(token.tokens) + if (token.type === 'html') return stripHtmlTags(token.text) + if ('text' in token && typeof token.text === 'string') return token.text + return '' + }) + .join('') + .trim() +} + const htmlAnchorRe = /]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi export type ToUserContentIdFn = (id: string) => string @@ -241,7 +263,7 @@ export function createHeading(options: { ) { const displayHtml = this.parser.parseInline(tokens) const plainText = getHeadingPlainText(displayHtml) - const slugSource = getHeadingSlugSource(displayHtml) + const slugSource = getHeadingSlugSourceFromTokens(tokens) return processHeading(depth, displayHtml, plainText, slugSource) } diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 717136f981..13ef15a7df 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -13,13 +13,13 @@ import { createImage, sanitizeRawHTML, decodeHashFragment, + emojiText, } from './mdKit' import matter from 'gray-matter' import { marked } from 'marked' import { hasProtocol } from 'ufo' import { convertBlobOrFileToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' import { decodeHtmlEntities, slugify } from '#shared/utils/html' -import { convertToEmoji } from '#shared/utils/emoji' import { toProxiedImageUrl } from '#server/utils/image-proxy' import { escapeHtml } from './docs/text' @@ -318,6 +318,7 @@ export async function renderReadmeHtml( } const renderer = new marked.Renderer() + renderer.text = emojiText // Collect playground links during parsing const collectedLinks: PlaygroundLink[] = [] @@ -378,7 +379,7 @@ export async function renderReadmeHtml( }) return { - html: convertToEmoji(sanitized), + html: sanitized, mdExists: Boolean(content), playgroundLinks: collectedLinks, toc, diff --git a/shared/utils/emoji.ts b/shared/utils/emoji.ts index 1a647236f8..df94dd66bb 100644 --- a/shared/utils/emoji.ts +++ b/shared/utils/emoji.ts @@ -1905,16 +1905,6 @@ const emojis: Record = { 'wales': '🏴󠁧󠁢󠁷󠁬󠁳󠁿', } -export function convertToEmoji(html: string): string { - return html.replace( - /(][\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 ![badge](badge.svg)' + 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:') }) })