From 78121c2dd5b53a4b21daef467e035d117e6e056f Mon Sep 17 00:00:00 2001 From: Baekspace Date: Mon, 14 Sep 2026 10:36:45 +0900 Subject: [PATCH] fix: support CJK-adjacent Markdown emphasis --- 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 23d1ecf3e34..370f3f2f53a 100644 --- a/package.json +++ b/package.json @@ -287,6 +287,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 7b3d847c1bf..8cf68047aca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -468,6 +468,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) @@ -5715,6 +5718,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==} @@ -5742,6 +5754,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==} @@ -6525,6 +6556,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==} @@ -12619,6 +12660,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 @@ -12687,6 +12738,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 @@ -13755,6 +13825,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 (