From 58fe92c90f1060f2bc4cacc2d1a5fab14fde008b Mon Sep 17 00:00:00 2001 From: Eran Krakovsky Date: Sun, 5 Jul 2026 11:21:58 +0300 Subject: [PATCH] fix: add RTL direction detection helpers --- apps/web/src/lib/rtl.test.ts | 49 ++++++++++++++++++++++++++++++++++++ apps/web/src/lib/rtl.ts | 40 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 apps/web/src/lib/rtl.test.ts create mode 100644 apps/web/src/lib/rtl.ts diff --git a/apps/web/src/lib/rtl.test.ts b/apps/web/src/lib/rtl.test.ts new file mode 100644 index 00000000000..f6c6db1f538 --- /dev/null +++ b/apps/web/src/lib/rtl.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vite-plus/test"; +import { containsRtl, dirFor, dirForMarkdown, isRtlText } from "./rtl"; + +describe("isRtlText", () => { + const cases: Array<[string, boolean]> = [ + ["שלום עולם", true], + ["مرحبا بالعالم", true], + ["سلام دنیا", true], + ["שלום hello עולם", true], + ["Note: بقية الجملة عربية طويلة جدا", true], + ["Hello world", false], + ["Hello world שלום", false], + ["", false], + ["12345 !@#$%", false], + ["ﭐﭑﭒ ﬠﬡ", true], + ]; + + for (const [input, expected] of cases) { + it(`${JSON.stringify(input)} -> ${expected}`, () => { + expect(isRtlText(input)).toBe(expected); + }); + } +}); + +describe("containsRtl", () => { + it("detects RTL without classifying unrelated scripts as RTL", () => { + expect(containsRtl("hello שלום")).toBe(true); + expect(containsRtl("hello world")).toBe(false); + expect(containsRtl("")).toBe(false); + expect(containsRtl("中文")).toBe(false); + expect(containsRtl("あいう")).toBe(false); + }); +}); + +describe("dirFor", () => { + it("returns rtl for RTL-majority text", () => { + expect(dirFor("שלום")).toBe("rtl"); + }); + + it("returns ltr for LTR text", () => { + expect(dirFor("hi")).toBe("ltr"); + }); +}); + +describe("dirForMarkdown", () => { + it("ignores markdown code when deciding direction", () => { + expect(dirForMarkdown("- **`apps/web`**: אפליקציית React שמציגה צ'אט")).toBe("rtl"); + }); +}); diff --git a/apps/web/src/lib/rtl.ts b/apps/web/src/lib/rtl.ts new file mode 100644 index 00000000000..1cf444adc49 --- /dev/null +++ b/apps/web/src/lib/rtl.ts @@ -0,0 +1,40 @@ +function isRtlChar(c: number): boolean { + return ( + (c >= 0x0590 && c <= 0x05ff) || + (c >= 0x0600 && c <= 0x06ff) || + (c >= 0x0750 && c <= 0x077f) || + (c >= 0x08a0 && c <= 0x08ff) || + (c >= 0xfb1d && c <= 0xfdff) || + (c >= 0xfe70 && c <= 0xfeff) + ); +} + +function isLtrChar(c: number): boolean { + return (c >= 0x41 && c <= 0x5a) || (c >= 0x61 && c <= 0x7a) || (c >= 0xc0 && c <= 0x024f); +} + +export function isRtlText(text: string): boolean { + let rtl = 0; + let ltr = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (isRtlChar(c)) rtl++; + else if (isLtrChar(c)) ltr++; + } + if (rtl === 0 && ltr === 0) return false; + return rtl > ltr; +} + +export function containsRtl(text: string): boolean { + return Array.from(text).some((char) => isRtlChar(char.charCodeAt(0))); +} + +export function dirFor(text: string): "rtl" | "ltr" { + return isRtlText(text) ? "rtl" : "ltr"; +} + +const MARKDOWN_DIRECTION_NEUTRAL_RE = /```[\s\S]*?(?:```|$)|`[^`\n]*`|https?:\/\/\S+/g; + +export function dirForMarkdown(text: string): "rtl" | "ltr" { + return dirFor(text.replace(MARKDOWN_DIRECTION_NEUTRAL_RE, "")); +}