Skip to content
Closed
1 change: 1 addition & 0 deletions apps/mobile/modules/t3-markdown-text/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export {
type NativeMarkdownDocumentChunk,
type NativeMarkdownTextRun,
} from "./src/nativeMarkdownText";
export { markdownNodeDirection, markdownTextDirection } from "./src/markdownTextDirection";
export { MarkdownTextPrimitive } from "./src/MarkdownTextPrimitive";
export {
SelectableMarkdownText,
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/modules/t3-markdown-text/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"react-native": "./index.ts",
"exports": {
".": "./index.ts",
"./direction": "./src/markdownTextDirection.ts",
"./file-icons": "./src/markdownFileIcons.ts",
"./links": "./src/markdownLinks.ts",
"./markdown": "./src/nativeMarkdownText.ts",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Image, ScrollView, Text, useColorScheme, View } from "react-native";
import type { MarkdownNode } from "react-native-nitro-markdown/headless";

import { CopyTextButton } from "./CopyTextButton";
import { markdownNodeDirection } from "./markdownTextDirection";
import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive";
import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText";
import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios";
Expand Down Expand Up @@ -510,13 +511,19 @@ function NativeList(props: {
return (
<View
key={nodeKey(item, index)}
style={{ alignItems: "flex-start", flexDirection: "row" }}
style={{
// Per item, not per list: a right-to-left item mirrors its own row and puts the
// marker on the side its text starts from, while its neighbours keep theirs.
direction: markdownNodeDirection(item),
alignItems: "flex-start",
flexDirection: "row",
}}
>
<View
style={{
width: markerWidth,
height: props.textStyle.lineHeight,
marginRight: 6,
marginEnd: 6,
alignItems: ordered ? "flex-end" : "center",
justifyContent: "flex-start",
}}
Expand Down Expand Up @@ -582,23 +589,29 @@ export function NativeMarkdownBlock(props: {
))}
</View>
);
// Code and tables read in source order, so they say so rather than inheriting: an Arabic list
// item or quote around one would otherwise mirror its header row and its columns.
case "code_block":
return (
<NativeCodeBlock
node={props.node}
textStyle={props.textStyle}
highlightCode={props.highlightCode}
compact={props.compact}
/>
<View style={{ direction: "ltr" }}>
<NativeCodeBlock
node={props.node}
textStyle={props.textStyle}
highlightCode={props.highlightCode}
compact={props.compact}
/>
</View>
);
case "table":
return (
<NativeTable
node={props.node}
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
/>
<View style={{ direction: "ltr" }}>
<NativeTable
node={props.node}
skills={props.skills}
textStyle={props.textStyle}
onLinkPress={props.onLinkPress}
/>
</View>
);
case "image":
return (
Expand All @@ -622,10 +635,11 @@ export function NativeMarkdownBlock(props: {
return (
<View
style={{
borderLeftColor: props.textStyle.quoteMarkerColor,
borderLeftWidth: 2,
direction: markdownNodeDirection(props.node),
borderStartColor: props.textStyle.quoteMarkerColor,
borderStartWidth: 2,
marginVertical: props.compact ? 4 : 0,
paddingLeft: 11,
paddingStart: 11,
paddingVertical: 2,
gap: 6,
}}
Expand Down
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();
});
});
51 changes: 51 additions & 0 deletions apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts
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 "";
}
21 changes: 17 additions & 4 deletions apps/mobile/src/features/threads/ThreadFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }}
Comment thread
cursor[bot] marked this conversation as resolved.
>
<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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RTL lists mirror code fences

Medium Severity

On the Android/nitro path, Arabic list rows set Yoga direction on a wrapper that also contains nested fences via MarkdownCodeBlock, which has no direction: "ltr" guard. iOS wraps code_block (and tables) in an LTR view for this reason; without the same shield, code headers and horizontal scroll chrome mirror inside RTL items.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c221eb7. Configure here.

}}
>
{ordered ? `${start + index}.` : "•"}
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/ChatMarkdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ import {
serializeTableElementToMarkdown,
} from "../markdown-clipboard";
import { remarkNormalizeListItemIndentation } from "../markdown-list-indentation";
import { rehypeAutoTextDirection } from "../markdown-text-direction";
import {
normalizeMarkdownLinkDestination,
resolveInlineCodeFileLinkMeta,
Expand Down Expand Up @@ -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,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
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. */
Expand Down Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dir="auto" on the trigger mirrors only the summary row: the trigger is flex … gap-2, so an Arabic title moves the chevron to the right, while the panel body below it still inherits LTR from the Collapsible root and keeps its ps-6 inset on the left (L577). The title and its body then hang off opposite edges.

Suggest putting dir="auto" on the Collapsible root instead of the trigger — the root's auto resolution skips the body paragraphs (they carry their own dir) and reads the summary text, so the title resolves the same way and ps-6 follows it.

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
Expand Down Expand Up @@ -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}
Expand Down
Loading
Loading