From 56e5cd5e340ac27cb5409634daedebb733f250a1 Mon Sep 17 00:00:00 2001
From: Neil <4138956+nwparker@users.noreply.github.com>
Date: Fri, 18 Sep 2026 19:15:45 -0700
Subject: [PATCH] fix(editor): render CJK-adjacent emphasis in Markdown preview
---
package.json | 1 +
pnpm-lock.yaml | 81 +++++++++++++++++++
.../editor/MarkdownPreviewBody.test.tsx | 67 +++++++++++++++
.../components/editor/MarkdownPreviewBody.tsx | 2 +
.../editor/markdown-table-of-contents.test.ts | 9 +++
.../editor/markdown-table-of-contents.ts | 6 +-
6 files changed, 164 insertions(+), 2 deletions(-)
create mode 100644 src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx
diff --git a/package.json b/package.json
index d49a8452ad8..c00bdb3b54e 100644
--- a/package.json
+++ b/package.json
@@ -289,6 +289,7 @@
"rehype-sanitize": "^6.0.0",
"rehype-slug": "^6.0.0",
"remark-breaks": "^4.0.0",
+ "remark-cjk-friendly": "^2.3.1",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-math": "^6.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 81856c9e805..ed7b75d764b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -472,6 +472,9 @@ importers:
remark-breaks:
specifier: ^4.0.0
version: 4.0.0
+ remark-cjk-friendly:
+ specifier: ^2.3.1
+ version: 2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5)
remark-frontmatter:
specifier: ^5.0.0
version: 5.0.0(supports-color@7.2.0)
@@ -5724,6 +5727,15 @@ packages:
mdast-util-to-hast@13.2.1:
resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==}
+ mdast-util-to-markdown-cjk-friendly@1.0.0:
+ resolution: {integrity: sha512-BoaAm8mlJ+LAYz0Qs532Y3ciTuQYgBUPZcSFbvC/ZKmEMAKgulw84YvQK1gI34t/vL2euSfuaWlqczkTBgamkw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/mdast': '*'
+ peerDependenciesMeta:
+ '@types/mdast':
+ optional: true
+
mdast-util-to-markdown@2.1.2:
resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==}
@@ -5751,6 +5763,25 @@ packages:
micromark-core-commonmark@2.0.3:
resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==}
+ micromark-extension-cjk-friendly-util@3.0.1:
+ resolution: {integrity: sha512-GcbXqTTHOsiZHyF753oIddP/J2eH8j9zpyQPhkof6B2JNxfEJabnQqxbCgzJNuNes0Y2jTNJ3LiYPSXr6eJA8w==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ micromark-util-types: '*'
+ peerDependenciesMeta:
+ micromark-util-types:
+ optional: true
+
+ micromark-extension-cjk-friendly@2.0.1:
+ resolution: {integrity: sha512-OkzoYVTL1ChbvQ8Cc1ayTIz7paFQz8iS9oIYmewncweUSwmWR+hkJF9spJ1lxB90XldJl26A1F4IkPOKS3bDXw==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ micromark: ^4.0.0
+ micromark-util-types: ^2.0.0
+ peerDependenciesMeta:
+ micromark-util-types:
+ optional: true
+
micromark-extension-frontmatter@2.0.0:
resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==}
@@ -6534,6 +6565,16 @@ packages:
remark-breaks@4.0.0:
resolution: {integrity: sha512-IjEjJOkH4FuJvHZVIW0QCDWxcG96kCq7An/KVH2NfJe6rKZU2AsHeB3OEjPNRxi4QC34Xdx7I2KGYn6IpT7gxQ==}
+ remark-cjk-friendly@2.3.1:
+ resolution: {integrity: sha512-f+pKZRxCRwNEGFBKNRAZAqU91GIK1SAo3ZyFHWRUgC9zcxRR0BXKd6YwqgSsxtW0rNpUDtONj7H5nje2WL3fcA==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@types/mdast': ^4.0.0
+ unified: ^11.0.0
+ peerDependenciesMeta:
+ '@types/mdast':
+ optional: true
+
remark-frontmatter@5.0.0:
resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==}
@@ -12632,6 +12673,16 @@ snapshots:
unist-util-visit: 5.1.0
vfile: 6.0.3
+ mdast-util-to-markdown-cjk-friendly@1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2):
+ dependencies:
+ mdast-util-to-markdown: 2.1.2
+ micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+ micromark-util-symbol: 2.0.1
+ optionalDependencies:
+ '@types/mdast': 4.0.4
+ transitivePeerDependencies:
+ - micromark-util-types
+
mdast-util-to-markdown@2.1.2:
dependencies:
'@types/mdast': 4.0.4
@@ -12700,6 +12751,25 @@ snapshots:
micromark-util-symbol: 2.0.1
micromark-util-types: 2.0.2
+ micromark-extension-cjk-friendly-util@3.0.1(micromark-util-types@2.0.2):
+ dependencies:
+ get-east-asian-width: 1.5.0
+ micromark-util-character: 2.1.1
+ micromark-util-symbol: 2.0.1
+ optionalDependencies:
+ micromark-util-types: 2.0.2
+
+ micromark-extension-cjk-friendly@2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0)):
+ dependencies:
+ devlop: 1.1.0
+ micromark: 4.0.2(supports-color@7.2.0)
+ micromark-extension-cjk-friendly-util: 3.0.1(micromark-util-types@2.0.2)
+ micromark-util-chunked: 2.0.1
+ micromark-util-resolve-all: 2.0.1
+ micromark-util-symbol: 2.0.1
+ optionalDependencies:
+ micromark-util-types: 2.0.2
+
micromark-extension-frontmatter@2.0.0:
dependencies:
fault: 2.0.1
@@ -13768,6 +13838,17 @@ snapshots:
mdast-util-newline-to-break: 2.0.0
unified: 11.0.5
+ remark-cjk-friendly@2.3.1(@types/mdast@4.0.4)(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))(unified@11.0.5):
+ dependencies:
+ mdast-util-to-markdown-cjk-friendly: 1.0.0(@types/mdast@4.0.4)(micromark-util-types@2.0.2)
+ micromark-extension-cjk-friendly: 2.0.1(micromark-util-types@2.0.2)(micromark@4.0.2(supports-color@7.2.0))
+ unified: 11.0.5
+ optionalDependencies:
+ '@types/mdast': 4.0.4
+ transitivePeerDependencies:
+ - micromark
+ - micromark-util-types
+
remark-frontmatter@5.0.0(supports-color@7.2.0):
dependencies:
'@types/mdast': 4.0.4
diff --git a/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx b/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx
new file mode 100644
index 00000000000..5554dba47ff
--- /dev/null
+++ b/src/renderer/src/components/editor/MarkdownPreviewBody.test.tsx
@@ -0,0 +1,67 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly'
+import remarkGfm from 'remark-gfm'
+import remarkParse from 'remark-parse'
+import { unified } from 'unified'
+import { describe, expect, it } from 'vitest'
+import { MarkdownPreviewBody } from './MarkdownPreviewBody'
+
+const cjkEmphasisExamples = [
+ {
+ name: 'Korean text after a quoted phrase',
+ markdown: '**"이런"**것은 강조됩니다.',
+ expectedHtml: '"이런"것은 강조됩니다.'
+ },
+ {
+ name: 'Korean text after parentheses',
+ markdown: '**(강조)**입니다.',
+ expectedHtml: '(강조)입니다.'
+ },
+ {
+ name: 'Korean text on both delimiter boundaries',
+ markdown: '문장은**"여기"**에서 이어집니다.',
+ expectedHtml: '문장은"여기"에서 이어집니다.'
+ },
+ {
+ name: 'Japanese text after corner brackets',
+ markdown: '**「強調」**です。',
+ expectedHtml: '「強調」です。'
+ },
+ {
+ name: 'Chinese text after quotation marks',
+ markdown: '**“强调”**文本',
+ expectedHtml: '“强调”文本'
+ }
+]
+
+const unchangedMarkdownExamples = [
+ '# Heading',
+ '**bold** text and *italic* text',
+ 'foo_bar_baz',
+ '`**literal**`',
+ '[link](https://example.com)',
+ '- [x] completed task',
+ '| left | right |\n| --- | --- |\n| a | b |',
+ '~~deleted~~ text',
+ '~~"삭제"~~문장'
+]
+
+function parseMarkdown(markdown: string) {
+ return unified().use(remarkParse).use(remarkGfm).parse(markdown)
+}
+
+function parseCjkFriendlyMarkdown(markdown: string) {
+ return unified().use(remarkParse).use(remarkGfm).use(remarkCjkFriendly).parse(markdown)
+}
+
+describe('MarkdownPreviewBody', () => {
+ it.each(cjkEmphasisExamples)('renders emphasis for $name', ({ markdown, expectedHtml }) => {
+ const html = renderToStaticMarkup()
+
+ expect(html).toContain(expectedHtml)
+ })
+
+ it.each(unchangedMarkdownExamples)('keeps existing parsing for %s', (markdown) => {
+ expect(parseCjkFriendlyMarkdown(markdown)).toEqual(parseMarkdown(markdown))
+ })
+})
diff --git a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx
index b29185f0321..3125405127b 100644
--- a/src/renderer/src/components/editor/MarkdownPreviewBody.tsx
+++ b/src/renderer/src/components/editor/MarkdownPreviewBody.tsx
@@ -7,6 +7,7 @@ import rehypeRaw from 'rehype-raw'
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'
import rehypeSlug from 'rehype-slug'
import remarkBreaks from 'remark-breaks'
+import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
@@ -55,6 +56,7 @@ const markdownPreviewSanitizeSchema = {
type MarkdownPluginList = NonNullable
const MARKDOWN_REMARK_PLUGINS: MarkdownPluginList = [
remarkGfm,
+ remarkCjkFriendly,
remarkBreaks,
remarkFrontmatter,
remarkMath,
diff --git a/src/renderer/src/components/editor/markdown-table-of-contents.test.ts b/src/renderer/src/components/editor/markdown-table-of-contents.test.ts
index ec0acbf2785..74b245085d4 100644
--- a/src/renderer/src/components/editor/markdown-table-of-contents.test.ts
+++ b/src/renderer/src/components/editor/markdown-table-of-contents.test.ts
@@ -98,6 +98,15 @@ describe('markdown table of contents', () => {
])
})
+ it('extracts emphasized headings next to Korean text', () => {
+ const toc = buildMarkdownTableOfContents('# **"이런"**것은 강조됩니다')
+
+ expect(toc[0]).toMatchObject({
+ id: '이런것은-강조됩니다',
+ title: '"이런"것은 강조됩니다'
+ })
+ })
+
it('uses GitHub-compatible duplicate slugs', () => {
const toc = buildMarkdownTableOfContents('# Repeat\n# Repeat')
diff --git a/src/renderer/src/components/editor/markdown-table-of-contents.ts b/src/renderer/src/components/editor/markdown-table-of-contents.ts
index e1bc30497e8..3b95e080781 100644
--- a/src/renderer/src/components/editor/markdown-table-of-contents.ts
+++ b/src/renderer/src/components/editor/markdown-table-of-contents.ts
@@ -1,3 +1,4 @@
+import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly'
import remarkFrontmatter from 'remark-frontmatter'
import remarkGfm from 'remark-gfm'
import remarkParse from 'remark-parse'
@@ -112,7 +113,7 @@ function appendTocItem(stack: MarkdownTocItem[], item: MarkdownTocItem): void {
}
type MarkdownAstNode = {
- alt?: string
+ alt?: string | null
children?: MarkdownAstNode[]
depth?: number
type?: string
@@ -142,8 +143,9 @@ export function buildMarkdownTableOfContents(markdown: string): MarkdownTocItem[
const tree = unified()
.use(remarkParse)
.use(remarkGfm)
+ .use(remarkCjkFriendly)
.use(remarkFrontmatter, ['yaml', 'toml'])
- .parse(markdown) as MarkdownAstNode
+ .parse(markdown)
function visit(node: MarkdownAstNode): void {
if (