Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions server/utils/changelog/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion server/utils/mdKit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
type Tokens,
type Token,
type RendererApi,
type Renderer,
type TokenizerObject,
Expand All @@ -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'
Expand Down Expand Up @@ -51,6 +53,12 @@ export const blockquote: RendererApi['blockquote'] = function (
return `<blockquote>${body}</blockquote>\n`
}

/** Convert emoji shortcodes only in Markdown text tokens. */
export const emojiText: RendererApi['text'] = function (this: Renderer<string, string>, 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
*/
Expand Down Expand Up @@ -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 = /<a(\s[^>]*?)href=(["'])([^"']*)\2([^>]*)>([\s\S]*?)<\/a>/gi

export type ToUserContentIdFn = (id: string) => string
Expand Down Expand Up @@ -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)
}

Expand Down
5 changes: 3 additions & 2 deletions server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -318,6 +318,7 @@ export async function renderReadmeHtml(
}

const renderer = new marked.Renderer()
renderer.text = emojiText

// Collect playground links during parsing
const collectedLinks: PlaygroundLink[] = []
Expand Down Expand Up @@ -378,7 +379,7 @@ export async function renderReadmeHtml(
})

return {
html: convertToEmoji(sanitized),
html: sanitized,
mdExists: Boolean(content),
playgroundLinks: collectedLinks,
toc,
Expand Down
14 changes: 2 additions & 12 deletions shared/utils/emoji.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1905,16 +1905,6 @@ const emojis: Record<string, string> = {
'wales': '🏴󠁧󠁢󠁷󠁬󠁳󠁿',
}

export function convertToEmoji(html: string): string {
return html.replace(
/(<code[\s>][\s\S]*?<\/code>|<pre[\s>][\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)
}
9 changes: 9 additions & 0 deletions test/unit/server/utils/changelog/markdown.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<p>🔢 <code>:1234:</code></p>')
})
})

describe('URL Resolution', () => {
describe('resolves from /markdown.md & releases', () => {
it('resolves relative .md links to blob URL for rendered viewing', async () => {
Expand Down
27 changes: 27 additions & 0 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,33 @@ function createRepoInfo(overrides?: Partial<RepositoryInfo>): 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('<p>🔢 <code>:1234:</code> :1234:</p>')
expect(result.html).toContain('<code class="language-text">:1234:</code>')
})

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` <span>now</span> ![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 () => {
Expand Down
11 changes: 3 additions & 8 deletions test/unit/shared/utils/emoji.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,10 @@ import { convertToEmoji } from '#shared/utils/emoji'

describe('convertToEmoji', () => {
it('converts :1234: to emoji', () => {
expect(convertToEmoji('<p>:1234:</p>')).toBe('<p>🔢</p>')
expect(convertToEmoji('Count :1234:')).toBe('Count 🔢')
})

it('leaves :1234: in codeblocks untouched', () => {
expect(convertToEmoji('<code>:1234:</code>')).toBe('<code>:1234:</code>')
})

it('converts :1234: to emoji outside of codeblock', () => {
expect(convertToEmoji('<p>:1234:</p><code>:1234:</code>')).toBe('<p>🔢</p><code>:1234:</code>')
expect(convertToEmoji('<code>:1234:</code><p>:1234:</p>')).toBe('<code>:1234:</code><p>🔢</p>')
it('leaves unknown shortcodes untouched', () => {
expect(convertToEmoji(':not-an-emoji:')).toBe(':not-an-emoji:')
})
})
Loading