diff --git a/apps/mobile/modules/t3-markdown-text/index.ts b/apps/mobile/modules/t3-markdown-text/index.ts index 89bce5395c8c..3f58ff2e3b45 100644 --- a/apps/mobile/modules/t3-markdown-text/index.ts +++ b/apps/mobile/modules/t3-markdown-text/index.ts @@ -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, diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index d51b6c5d9ff7..706528aad53b 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -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", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index 5fbe6d4dff44..64f01d99f1ea 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -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"; @@ -510,13 +511,19 @@ function NativeList(props: { return ( ); + // 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 ( - + + + ); case "table": return ( - + + + ); case "image": return ( @@ -622,10 +635,11 @@ export function NativeMarkdownBlock(props: { return ( { + 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(); + }); +}); diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts new file mode 100644 index 000000000000..8d91e000c33b --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownTextDirection.ts @@ -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 ""; +} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..722a34c34615 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -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 {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 ( - + + + ); } return ( - + {ordered ? `${start + index}.` : "•"} diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index c4548540e2ce..ec7ae20248af 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -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, +] satisfies NonNullable; + +/** User messages render without raw HTML, and their Arabic needs a direction just the same. */ +const CHAT_MARKDOWN_TEXT_DIRECTION_PLUGINS = [ + rehypeAutoTextDirection, ] satisfies NonNullable; /** 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"} > code { + /* Isolated, not just pinned: inside a right-to-left paragraph the bidi algorithm reorders the + neutrals bounding a Latin run with the text around it, so `--flag` reads `flag--` and + `foo()` reads `()foo`. Isolation keeps the span a run of its own; ltr is its source order. */ + direction: ltr; + unicode-bidi: isolate; border: 1px solid var(--border); border-radius: 0.375rem; background: var(--muted); @@ -1760,6 +1795,8 @@ code { } .chat-markdown pre { + /* Code reads in source order, whatever direction the block around it resolved to. */ + direction: ltr; max-width: 100%; overflow-x: auto; border: 1px solid var(--border); @@ -1832,7 +1869,7 @@ code { .chat-markdown th, .chat-markdown td { padding: 0.45rem 0.75rem; - text-align: left; + text-align: start; } .chat-markdown thead th { diff --git a/apps/web/src/markdown-text-direction.test.tsx b/apps/web/src/markdown-text-direction.test.tsx new file mode 100644 index 000000000000..7e83c6a47c21 --- /dev/null +++ b/apps/web/src/markdown-text-direction.test.tsx @@ -0,0 +1,92 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { describe, expect, it } from "vite-plus/test"; + +import { rehypeAutoTextDirection } from "./markdown-text-direction"; + +function renderMarkdown(markdown: string): string { + return renderToStaticMarkup( + + {markdown} + , + ); +} + +describe("rehypeAutoTextDirection", () => { + it("marks each text block so the browser reads its own direction", () => { + const html = renderMarkdown(`# مرحبا + +مرحبا بالعالم + +Hello world + +- عنصر +- item`); + + expect(html).toContain('

مرحبا

'); + expect(html).toContain('

مرحبا بالعالم

'); + expect(html).toContain('

Hello world

'); + expect(html).toContain('
  • عنصر
  • '); + expect(html).toContain('
  • item
  • '); + }); + + it("tags only the outermost block, since auto skips dir-bearing descendants", () => { + const html = renderMarkdown(`> مرحبا بالعالم + +- عنصر + + فقرة ثانية`); + + // The quote reads its own direction; a tagged paragraph inside would blind it. + expect(html).toContain('
    '); + expect(html).toContain("

    مرحبا بالعالم

    "); + // Lists never take a `dir` of their own, so each item keeps one. + expect(html).not.toContain("
      '); + expect(html).toContain("

      عنصر

      "); + }); + + it("lets a nested list item read its own direction, not its parent's", () => { + const html = renderMarkdown(`- عنصر عربي + - English child`); + + expect(html).toContain('
    • عنصر عربي'); + expect(html).toContain('
    • English child
    • '); + }); + + it("keeps a list-only quote's items untagged, so the rail still has text to read", () => { + const html = renderMarkdown(`> - عنصر +> - عنصر آخر`); + + expect(html).toContain('
      '); + expect(html).not.toContain("
    • { + expect(renderMarkdown("- عنصر\n- item")).toContain('
        '); + expect(renderMarkdown("1. عنصر")).toContain('
          '); + // An all-English list keeps the end side flush with the text around it. + expect(renderMarkdown("- item\n- another")).toContain("
            "); + // Only the nested list carries the Arabic item, so only it gets the gutter. + const nested = renderMarkdown("- item\n - عنصر"); + expect(nested).toContain('
              \n
            • item'); + expect(nested).toContain('
                '); + }); + + it("leaves code and layout containers alone", () => { + const html = renderMarkdown(`\`\`\`ts +const مرحبا = 1; +\`\`\` + +| a | ب | +| - | - | +| 1 | ٢ |`); + + expect(html).toContain("
                ب');
                +    expect(html).toContain('٢');
                +  });
                +});
                diff --git a/apps/web/src/markdown-text-direction.ts b/apps/web/src/markdown-text-direction.ts
                new file mode 100644
                index 000000000000..1f0d11350fd4
                --- /dev/null
                +++ b/apps/web/src/markdown-text-direction.ts
                @@ -0,0 +1,112 @@
                +/**
                + * Mixed Arabic/English markdown reads wrong under a single LTR direction: an Arabic paragraph
                + * keeps its trailing punctuation on the left, and a line that opens in Arabic is laid out from
                + * the wrong edge. `dir="auto"` hands each text block to the browser's own bidi algorithm, which
                + * picks direction from the block's first strong character — per block, so an Arabic paragraph
                + * and the English one under it each get their own.
                + *
                + * Text blocks only. Code and layout containers stay as they are: reversing a `
                ` or a
                + * table's column order changes meaning rather than presentation.
                + */
                +
                +interface HastNode {
                +  type?: string;
                +  tagName?: string;
                +  value?: string;
                +  properties?: Record;
                +  children?: HastNode[];
                +}
                +
                +const AUTO_DIRECTION_TAGS = new Set([
                +  "p",
                +  "li",
                +  "blockquote",
                +  "h1",
                +  "h2",
                +  "h3",
                +  "h4",
                +  "h5",
                +  "h6",
                +  "td",
                +  "th",
                +  "dd",
                +  "dt",
                +  "summary",
                +  "figcaption",
                +]);
                +
                +/**
                + * The auto algorithm skips text inside descendants that carry their own `dir`, so tagging a
                + * block and its children leaves the outer one with nothing to read and it silently falls back
                + * to LTR — a quote whose paragraph is Arabic would keep its rule on the left. Only the
                + * outermost block of a nest gets tagged; everything under it inherits.
                + *
                + * `ul`/`ol` are deliberately absent so their items stay the outermost blocks and keep a
                + * direction each. That leaves the list itself LTR, which is what `data-rtl-item` below is for.
                + */
                +function isAutoDirectionBlock(node: HastNode): boolean {
                +  if (node.type !== "element" || !node.tagName) return false;
                +  // A GitHub alert renders as a titled callout whose title is always English, so reading a
                +  // direction off the quote as a whole would pin every alert to LTR. Its paragraphs are left
                +  // to speak for themselves.
                +  if (node.properties?.dataAlert != null) return false;
                +  return AUTO_DIRECTION_TAGS.has(node.tagName);
                +}
                +
                +const FIRST_LETTER = /\p{L}/u;
                +
                +/**
                + * The living right-to-left scripts. 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 in.
                + * Kept in step with the mobile client's `markdownTextDirection`, which reads the same rule.
                + */
                +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;
                +
                +function firstLetter(node: HastNode): string | undefined {
                +  if (node.type === "text") return FIRST_LETTER.exec(node.value ?? "")?.[0];
                +  for (const child of node.children ?? []) {
                +    const letter = firstLetter(child);
                +    if (letter) return letter;
                +  }
                +  return undefined;
                +}
                +
                +/**
                + * A right-to-left item hangs its marker on the list's end side, where an LTR list has no gutter,
                + * so the list has to be told it holds one. `:dir(rtl)` would say it in CSS alone, but the build
                + * lowers that selector to a `:lang()` list which never matches a `dir="auto"` element.
                + *
                + * The first strong letter is the same rule `dir="auto"` itself follows, so the two agree; where
                + * they don't, the cost is 1.25rem of unused padding rather than text on the wrong side.
                + */
                +function holdsRightToLeftItem(list: HastNode): boolean {
                +  return (list.children ?? []).some((child) => {
                +    if (child.tagName !== "li") return false;
                +    const letter = firstLetter(child);
                +    return letter != null && RIGHT_TO_LEFT_LETTER.test(letter);
                +  });
                +}
                +
                +export function rehypeAutoTextDirection() {
                +  return (tree: HastNode) => {
                +    const visit = (node: HastNode, taggedAncestor: string | undefined) => {
                +      const tag = taggedAncestor === undefined && isAutoDirectionBlock(node);
                +      if (tag) {
                +        node.properties = { ...node.properties, dir: "auto" };
                +      }
                +      const isList = node.tagName === "ul" || node.tagName === "ol";
                +      if (isList && holdsRightToLeftItem(node)) {
                +        node.properties = { ...node.properties, dataRtlItem: "" };
                +      }
                +      // A list under an item starts the outermost-only rule over, so an English item nested under
                +      // an Arabic one still reads its own way round: the item above it keeps its own line for
                +      // `auto` to read. A list under a quote does not get that — a quote's whole content can be
                +      // the list, and tagging the items would leave the rail nothing to read and pin it left.
                +      const childAncestor =
                +        isList && taggedAncestor === "li" ? undefined : tag ? node.tagName : taggedAncestor;
                +      node.children?.forEach((child) => visit(child, childAncestor));
                +    };
                +    visit(tree, undefined);
                +  };
                +}