From 75c8fea3df4fd2e4844626fcf638553311387f16 Mon Sep 17 00:00:00 2001 From: rebelchris Date: Wed, 10 Jun 2026 13:51:57 +0000 Subject: [PATCH] fix(markdown): support underscore emphasis near punctuation --- __tests__/common/markdown.ts | 46 ++++++++++++++++++++ src/common/markdown.ts | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+) diff --git a/__tests__/common/markdown.ts b/__tests__/common/markdown.ts index 5f96c55c96..5b58a7501e 100644 --- a/__tests__/common/markdown.ts +++ b/__tests__/common/markdown.ts @@ -129,3 +129,49 @@ Some text here }); }); }); + +describe('markdown emphasis rendering', () => { + it.each([ + ['WORD_(text)_', 'WORD(text)'], + ['word_(text)_', 'word(text)'], + ['”_(text)_word', '”(text)word'], + ['“WORD”_(text)_', '“WORD”(text)'], + [ + '“SCARRING” _(Oh my goshhh)_ effect', + '“SCARRING” (Oh my goshhh) effect', + ], + ])( + 'should render underscores around punctuation as emphasis: %s', + (content, expected) => { + expect(markdown.renderInline(content)).toBe(expected); + }, + ); + + it.each([ + ['some_file_name', 'some_file_name'], + ['user_id', 'user_id'], + ['a_b_c', 'a_b_c'], + ['__init__', 'init'], + ['`some_var`', 'some_var'], + [ + 'http://x/a_b_c', + 'http://x/a_b_c', + ], + ])( + 'should preserve existing underscore behavior: %s', + (content, expected) => { + expect(markdown.renderInline(content)).toBe(expected); + }, + ); + + it.each([ + ['before *(text)* after', 'before (text) after'], + ['before **(text)** after', 'before (text) after'], + ['before __(text)__ after', 'before (text) after'], + ])( + 'should preserve non-single-underscore emphasis: %s', + (content, expected) => { + expect(markdown.renderInline(content)).toBe(expected); + }, + ); +}); diff --git a/src/common/markdown.ts b/src/common/markdown.ts index 500f623c2e..7545cf3eb2 100644 --- a/src/common/markdown.ts +++ b/src/common/markdown.ts @@ -1,4 +1,5 @@ import MarkdownIt, { Renderer, Token } from 'markdown-it'; +import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'; import hljs from 'highlight.js'; import { getUserProfileUrl } from './users'; import { CommentMention, PostMention, User } from '../entity'; @@ -9,6 +10,10 @@ import { ghostUser } from './utils'; import { isValidHttpUrl } from './links'; import { getProxiedImageUrl } from './imageProxy'; +const underscoreMarker = 0x5f; +const alphaNumericRegex = /[\p{L}\p{N}]/u; +const adjustedDelimiterStates = new WeakSet(); + /** * Sanitizes HTML content, allowing only safe tags for rich text content. * Used for opportunity content sections (WYSIWYG editor output). @@ -49,6 +54,85 @@ export const markdown: MarkdownIt = MarkdownIt({ }, }); +const isPunctuationChar = (charCode: number | undefined): boolean => + typeof charCode === 'number' && + (markdown.utils.isMdAsciiPunct(charCode) || + markdown.utils.isPunctChar(String.fromCharCode(charCode))); + +const isAlphaNumericChar = (charCode: number | undefined): boolean => + typeof charCode === 'number' && + alphaNumericRegex.test(String.fromCharCode(charCode)); + +const getCharCode = ({ + state, + pos, +}: { + state: StateInline; + pos: number; +}): number | undefined => + pos >= 0 && pos < state.posMax ? state.src.charCodeAt(pos) : undefined; + +const getPunctuationBoundary = ({ + state, + start, + length, +}: { + state: StateInline; + start: number; + length: number; +}): { canOpen: boolean; canClose: boolean } => { + const previousChar = getCharCode({ state, pos: start - 1 }); + const nextChar = getCharCode({ state, pos: start + length }); + + return { + canOpen: isAlphaNumericChar(previousChar) && isPunctuationChar(nextChar), + canClose: isPunctuationChar(previousChar) && isAlphaNumericChar(nextChar), + }; +}; + +const adjustUnderscoreDelimiterScan = (state: StateInline): void => { + const defaultScanDelims = state.scanDelims.bind(state); + + state.scanDelims = (start, canSplitWord) => { + const scanned = defaultScanDelims(start, canSplitWord); + + if ( + canSplitWord || + state.src.charCodeAt(start) !== underscoreMarker || + scanned.length !== 1 + ) { + return scanned; + } + + const boundary = getPunctuationBoundary({ + state, + start, + length: scanned.length, + }); + + return { + ...scanned, + can_open: scanned.can_open || boundary.canOpen, + can_close: scanned.can_close || boundary.canClose, + }; + }; +}; + +markdown.inline.ruler.before( + 'emphasis', + 'underscore_punctuation_emphasis', + (state: StateInline, silent: boolean) => { + if (silent || adjustedDelimiterStates.has(state)) { + return false; + } + + adjustUnderscoreDelimiterScan(state); + adjustedDelimiterStates.add(state); + + return false; + }, +); + export const renderMarkdown = ( content: string, env: Record = {},