diff --git a/mobile/package.json b/mobile/package.json index 2753365cae6..675ff465a19 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -54,6 +54,7 @@ "lowlight": "^3.3.0", "lucide-react-native": "^1.14.0", "markdown-it": "^14.3.2", + "markdown-it-cjk-friendly": "^2.0.3", "markdown-it-footnote": "^4.0.0", "mermaid": "11.17.2", "react": "19.2.8", diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index faf82cf389f..dfbb6b5115d 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: markdown-it: specifier: 14.3.3 version: 14.3.3 + markdown-it-cjk-friendly: + specifier: ^2.0.3 + version: 2.0.3(@types/markdown-it@14.1.2)(markdown-it@14.3.3) markdown-it-footnote: specifier: ^4.0.0 version: 4.0.0 @@ -4650,6 +4653,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.7.0: + resolution: {integrity: sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -5558,6 +5565,16 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + markdown-it-cjk-friendly@2.0.3: + resolution: {integrity: sha512-dBAhqp7GZ6he15ug2t5JSfwTw2KvZX9tt0y7Up0reR1cZOBUogSq5V859hcIGwge7hjW1SkUaWkNr++0HszyZg==} + engines: {node: '>=18'} + peerDependencies: + '@types/markdown-it': '*' + markdown-it: 14.3.3 + peerDependenciesMeta: + '@types/markdown-it': + optional: true + markdown-it-footnote@4.0.0: resolution: {integrity: sha512-WYJ7urf+khJYl3DqofQpYfEYkZKbmXmwxQV8c8mO/hGIhgZ1wOe7R4HLFNwqx7TjILbnC98fuyeSsin19JdFcQ==} @@ -12448,6 +12465,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.7.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -13550,6 +13569,13 @@ snapshots: dependencies: tmpl: 1.0.5 + markdown-it-cjk-friendly@2.0.3(@types/markdown-it@14.1.2)(markdown-it@14.3.3): + dependencies: + get-east-asian-width: 1.7.0 + markdown-it: 14.3.3 + optionalDependencies: + '@types/markdown-it': 14.1.2 + markdown-it-footnote@4.0.0: {} markdown-it@14.3.3: diff --git a/mobile/src/components/mobile-markdown-tree.test.ts b/mobile/src/components/mobile-markdown-tree.test.ts new file mode 100644 index 00000000000..382cef6fbf8 --- /dev/null +++ b/mobile/src/components/mobile-markdown-tree.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseMobileMarkdownTree } from './mobile-markdown-tree' + +function inlineNodes(source: string) { + const [paragraph] = parseMobileMarkdownTree(source, false).children + return paragraph?.type === 'paragraph' ? paragraph.children : [] +} + +describe('CJK emphasis in mobile markdown', () => { + it('closes bold that ends in punctuation right before CJK text', () => { + expect( + inlineNodes('**单算 Runtime 执行引擎,平均每题 29.78 秒。**边界是开始执行到产出结果。') + ).toContainEqual({ + type: 'strong', + children: [{ type: 'text', value: '单算 Runtime 执行引擎,平均每题 29.78 秒。' }] + }) + }) + + it('keeps CommonMark flanking rules between non-CJK characters', () => { + expect(inlineNodes('**Note.**Next')).toEqual([{ type: 'text', value: '**Note.**Next' }]) + }) +}) diff --git a/mobile/src/components/mobile-markdown-tree.ts b/mobile/src/components/mobile-markdown-tree.ts index 54bc12f67fd..15aea826e31 100644 --- a/mobile/src/components/mobile-markdown-tree.ts +++ b/mobile/src/components/mobile-markdown-tree.ts @@ -1,4 +1,5 @@ import MarkdownIt from 'markdown-it' +import cjkFriendly from 'markdown-it-cjk-friendly' import footnote from 'markdown-it-footnote' import type { AlignType, @@ -14,9 +15,10 @@ import remend from 'remend' import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html' import { endLinkifyUrlsAtProsePunctuation } from './markdown-linkify-prose-boundary' -// CommonMark with GitHub's tables, strikethrough, autolinks and footnotes: the rules desktop's -// renderer follows, read by a parser fast enough for the phone's JavaScript engine. -const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote) +// CommonMark with GitHub's tables, strikethrough, autolinks, footnotes and CJK-friendly +// emphasis: the rules desktop's renderer follows, read by a parser fast enough for the phone's +// JavaScript engine. +const markdown = new MarkdownIt({ html: true, linkify: true }).use(footnote).use(cjkFriendly) // Every href parses as a link, file: included; routeMarkdownHref decides what a tap opens. markdown.validateLink = () => true endLinkifyUrlsAtProsePunctuation(markdown.linkify) diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx index 364d4d93e5a..c7e613ddcaf 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.test.tsx @@ -333,6 +333,23 @@ function renderComment(content: string, variant: 'compact' | 'document' = 'docum return renderToStaticMarkup() } +describe('CJK emphasis', () => { + it('closes bold that ends in punctuation right before CJK text', () => { + expect( + renderComment('**单算 Runtime 执行引擎,平均每题 29.78 秒。**边界是开始执行到产出结果。') + ).toContain( + '单算 Runtime 执行引擎,平均每题 29.78 秒。边界是开始执行到产出结果。' + ) + }) + + it('keeps CommonMark flanking rules between non-CJK characters', () => { + const markup = renderComment('**Note.**Next') + + expect(markup).not.toContain('') + expect(markup).toContain('**Note.**Next') + }) +}) + describe('GitHub callouts', () => { it.each(['compact', 'document'] as const)( 'renders a %s quote that opens with a marker as a titled callout', diff --git a/src/renderer/src/components/sidebar/CommentMarkdown.tsx b/src/renderer/src/components/sidebar/CommentMarkdown.tsx index ba7a87c2f11..089f62e2580 100644 --- a/src/renderer/src/components/sidebar/CommentMarkdown.tsx +++ b/src/renderer/src/components/sidebar/CommentMarkdown.tsx @@ -6,6 +6,7 @@ import { import Markdown, { defaultUrlTransform, type Components } from 'react-markdown' import remarkGfm from 'remark-gfm' import remarkBreaks from 'remark-breaks' +import remarkCjkFriendly from 'remark-cjk-friendly/parseOnly' import rehypeRaw from 'rehype-raw' import rehypeSanitize, { defaultSchema, type Options as SanitizeSchema } from 'rehype-sanitize' import remend from 'remend' @@ -83,6 +84,7 @@ const commentMarkdownFileUriUrlTransform: UrlTransform = (value, key, node) => { // with existing plain-text comments that rely on newline formatting. const remarkPlugins = [ remarkGfm, + remarkCjkFriendly, remarkAutolinkLiteralProseBoundary, remarkGitHubCallouts, remarkBreaks