Repository navigation
feat: read Arabic markdown blocks right-to-left #6575
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d265330
98e277f
4354b56
e554302
4a59ff3
4ccc319
68acb73
c221eb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
| import { describe, expect, it } from "vite-plus/test"; | ||
|
|
||
| import { markdownNodeDirection, markdownTextDirection } from "./markdownTextDirection"; | ||
|
|
||
| describe("markdownTextDirection", () => { | ||
| it("reads the first letter, ignoring markers and punctuation before it", () => { | ||
| expect(markdownTextDirection("مرحبا بالعالم")).toBe("rtl"); | ||
| expect(markdownTextDirection(" — «مرحبا»")).toBe("rtl"); | ||
| expect(markdownTextDirection("שלום עולם")).toBe("rtl"); | ||
| expect(markdownTextDirection("ࡀࡁࡂ")).toBe("rtl"); | ||
| expect(markdownTextDirection("Hello world")).toBe("ltr"); | ||
| expect(markdownTextDirection("1. Hello, مرحبا")).toBe("ltr"); | ||
| // No letter to read: left to inherit rather than forced either way round. | ||
| expect(markdownTextDirection("42 — 3.14")).toBeUndefined(); | ||
| expect(markdownTextDirection("")).toBeUndefined(); | ||
| }); | ||
| }); | ||
|
|
||
| describe("markdownNodeDirection", () => { | ||
| const node = (type: string, children: MarkdownNode[]): MarkdownNode => | ||
| ({ type, children }) as MarkdownNode; | ||
| const text = (content: string): MarkdownNode => ({ type: "text", content }) as MarkdownNode; | ||
|
|
||
| it("descends to the first child that actually reads as text", () => { | ||
| expect( | ||
| markdownNodeDirection(node("list_item", [node("paragraph", [text(" "), text("مرحبا")])])), | ||
| ).toBe("rtl"); | ||
| expect(markdownNodeDirection(node("list_item", [node("paragraph", [text("item")])]))).toBe( | ||
| "ltr", | ||
| ); | ||
| expect(markdownNodeDirection(node("list_item", []))).toBeUndefined(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| /** | ||
| * The web client hands each markdown block to the browser with `dir="auto"` and lets it pick a | ||
| * direction from the first strong character. React Native has no equivalent: Yoga will not | ||
| * mirror a row or resolve start/end padding without an explicit `direction`, so the same rule is | ||
| * applied here by hand, per block, to keep the two clients reading alike. | ||
| * | ||
| * Text itself needs no help — iOS resolves natural alignment from the paragraph's own base | ||
| * writing direction and Android's default text direction is first-strong. This is only for the | ||
| * chrome around the text: a list marker and a quote rail have to be told which side they are on. | ||
| */ | ||
|
|
||
| import type { MarkdownNode } from "react-native-nitro-markdown/headless"; | ||
|
|
||
| const FIRST_LETTER = /\p{L}/u; | ||
|
|
||
| /** | ||
| * The living right-to-left scripts, which is where the first-strong rule earns its keep. Not the | ||
| * full Unicode R/AL bidi classes — those are not expressible as a property escape in JS — but | ||
| * every script a message is realistically written in. | ||
| */ | ||
| const RIGHT_TO_LEFT_LETTER = | ||
| /[\p{Script=Arabic}\p{Script=Hebrew}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}\p{Script=Adlam}\p{Script=Samaritan}\p{Script=Mandaic}\p{Script=Hanifi_Rohingya}\p{Script=Yezidi}]/u; | ||
|
|
||
| /** | ||
| * A left-to-right block says so rather than staying silent: Yoga inherits `direction`, so an | ||
| * English item under an Arabic one would otherwise keep its parent's mirrored chrome. Only a | ||
| * block with no letter at all — a bare number, a lone link — is left to inherit. | ||
| */ | ||
| export function markdownTextDirection(text: string): "ltr" | "rtl" | undefined { | ||
| const firstLetter = FIRST_LETTER.exec(text)?.[0]; | ||
| if (!firstLetter) return undefined; | ||
| return RIGHT_TO_LEFT_LETTER.test(firstLetter) ? "rtl" : "ltr"; | ||
| } | ||
|
|
||
| export function markdownNodeDirection(node: MarkdownNode): "ltr" | "rtl" | undefined { | ||
| return markdownTextDirection(markdownNodeText(node)); | ||
| } | ||
|
|
||
| function markdownNodeText(node: MarkdownNode): string { | ||
| if (node.content !== undefined) { | ||
| return node.content; | ||
| } | ||
| // Stops at the first block that reads as text; a marker's own direction comes from its item. | ||
| for (const child of node.children ?? []) { | ||
| const text = markdownNodeText(child); | ||
| if (FIRST_LETTER.test(text)) { | ||
| return text; | ||
| } | ||
| } | ||
| return ""; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -82,6 +82,7 @@ import { | |
| import { MOBILE_TYPOGRAPHY } from "../../lib/typography"; | ||
| import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; | ||
| import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; | ||
| import { markdownNodeDirection } from "@t3tools/mobile-markdown-text/direction"; | ||
| import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; | ||
| import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; | ||
| import { | ||
|
|
@@ -585,22 +586,34 @@ function useMarkdownStyles(onLinkPress: (href: string) => void): MarkdownStyleSe | |
| <View className="mt-0.5 mb-2"> | ||
| {node.children?.map((child, index) => { | ||
| const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; | ||
| // Per item: a right-to-left item mirrors its own row so the marker lands on the side | ||
| // its text starts from. Matches the web client's per-block direction. | ||
| const itemDirection = markdownNodeDirection(child); | ||
| if (child.type === "task_list_item") { | ||
| // Its checkbox row is the renderer's own, so the direction goes on a wrapper — | ||
| // without it a task item is the one row in the list that never mirrors. | ||
| return ( | ||
| <Renderer key={childKey} node={child} depth={1} inListItem parentIsText={false} /> | ||
| <View key={childKey} style={{ direction: itemDirection }}> | ||
| <Renderer node={child} depth={1} inListItem parentIsText={false} /> | ||
| </View> | ||
| ); | ||
| } | ||
| return ( | ||
| <View className="mb-[3px] flex-row items-start" key={childKey}> | ||
| <View | ||
| className="mb-[3px] flex-row items-start" | ||
| key={childKey} | ||
| style={{ direction: itemDirection }} | ||
| > | ||
| <NativeText | ||
| className="font-sans" | ||
| style={{ | ||
| width: ordered ? 22 : 12, | ||
| marginRight: 5, | ||
| marginEnd: 5, | ||
| color: inlineTextColor, | ||
| fontSize: markdownFontSizes.m, | ||
| lineHeight: markdownFontSizes.bodyLineHeight, | ||
| textAlign: ordered ? "right" : "center", | ||
| // A number hugs the text it labels, which the mirrored row moved to its left. | ||
| textAlign: ordered ? (itemDirection === "rtl" ? "left" : "right") : "center", | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. RTL lists mirror code fencesMedium Severity On the Android/nitro path, Arabic list rows set Yoga Additional Locations (2)Reviewed by Cursor Bugbot for commit c221eb7. Configure here. |
||
| }} | ||
| > | ||
| {ordered ? `${start + index}.` : "•"} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,7 @@ import { | |
| serializeTableElementToMarkdown, | ||
| } from "../markdown-clipboard"; | ||
| import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation"; | ||
| import { rehypeAutoTextDirection } from "../markdown-text-direction"; | ||
| import { | ||
| normalizeMarkdownLinkDestination, | ||
| resolveInlineCodeFileLinkMeta, | ||
|
|
@@ -209,6 +210,13 @@ const CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS = [ | |
| const CHAT_MARKDOWN_REHYPE_PLUGINS = [ | ||
| rehypeRaw, | ||
| [rehypeSanitize, CHAT_MARKDOWN_SANITIZE_SCHEMA], | ||
| // After the sanitizer: `dir` is ours, not the message's, and the schema stays untouched. | ||
| rehypeAutoTextDirection, | ||
|
macroscopeapp[bot] marked this conversation as resolved.
cursor[bot] marked this conversation as resolved.
|
||
| ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | ||
|
|
||
| /** User messages render without raw HTML, and their Arabic needs a direction just the same. */ | ||
| const CHAT_MARKDOWN_TEXT_DIRECTION_PLUGINS = [ | ||
| rehypeAutoTextDirection, | ||
| ] satisfies NonNullable<ReactMarkdownOptions["rehypePlugins"]>; | ||
|
|
||
| /** GitHub's own five alert kinds, in its colors: the glyph names the urgency, the title says it. */ | ||
|
|
@@ -553,7 +561,10 @@ function MarkdownDetails({ | |
| data-markdown-details-open={isOpen ? "true" : "false"} | ||
| > | ||
| <CollapsibleTrigger | ||
| className="flex w-full items-center gap-2 py-2 text-left text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90" | ||
| // The summary element itself is dropped here, so its `dir` has to be reapplied on the | ||
| // row that replaces it — otherwise an Arabic title reads left-to-right under its own body. | ||
| dir="auto" | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggest putting Posted via Macroscope — UI Consistency |
||
| className="flex w-full items-center gap-2 py-2 text-start text-sm font-medium text-foreground data-panel-open:[&_svg]:rotate-90" | ||
| data-markdown-details-summary="" | ||
| > | ||
| <ChevronRightIcon | ||
|
|
@@ -1798,7 +1809,9 @@ function ChatMarkdown({ | |
| remarkPlugins={ | ||
| lineBreaks ? CHAT_MARKDOWN_REMARK_PLUGINS_WITH_BREAKS : CHAT_MARKDOWN_REMARK_PLUGINS | ||
| } | ||
| rehypePlugins={parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : undefined} | ||
| rehypePlugins={ | ||
| parseRawHtml ? CHAT_MARKDOWN_REHYPE_PLUGINS : CHAT_MARKDOWN_TEXT_DIRECTION_PLUGINS | ||
| } | ||
| skipHtml={false} | ||
| components={markdownComponents} | ||
| urlTransform={markdownUrlTransform} | ||
|
|
||


Uh oh!
There was an error while loading. Please reload this page.