diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 83b0bef9c022..ba144dc82522 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -63,6 +63,7 @@ import { } from "react"; import { Markdown, + parseMarkdown, type CustomRenderers, type NodeStyleOverrides, type PartialMarkdownTheme, @@ -97,6 +98,7 @@ import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { tryOpenExternalUrl } from "../../lib/openExternalUrl"; import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; import { hasWideMarkdownBlock } from "../../lib/wideMarkdownBlocks"; +import { resolveMarkdownNodeTextDirection, type TextDirection } from "../../lib/textDirection"; import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { hasNativeSelectableMarkdownText, @@ -615,7 +617,7 @@ interface MarkdownStyleSets { interface MarkdownStyleSet { readonly theme: PartialMarkdownTheme; readonly styles: NodeStyleOverrides; - readonly renderers: CustomRenderers; + readonly renderers: Readonly>; readonly nativeTextStyle: NativeMarkdownTextStyle; } @@ -699,6 +701,8 @@ function MarkdownInlineCode(props: { color: presentation ? props.textColor : props.codeColor, fontSize: props.fontSize, lineHeight: props.lineHeight, + textAlign: "left", + writingDirection: "ltr", }} > {presentation ? ( @@ -772,6 +776,65 @@ function ArtifactTemplateCard(props: { ); } +const NitroMarkdownMessage = memo(function NitroMarkdownMessage(props: { + readonly text: string; + readonly markdownStyles: MarkdownStyleSet; +}) { + const sourceAst = useMemo(() => parseMarkdown(props.text, { gfm: true }), [props.text]); + const direction = resolveMarkdownNodeTextDirection(sourceAst); + const styles = useMemo( + () => ({ + ...props.markdownStyles.styles, + document: { + ...props.markdownStyles.styles.document, + direction, + }, + paragraph: { + ...props.markdownStyles.styles.paragraph, + direction, + }, + list: { + ...props.markdownStyles.styles.list, + direction, + }, + list_item: { + ...props.markdownStyles.styles.list_item, + direction, + }, + task_list_item: { + ...props.markdownStyles.styles.task_list_item, + direction, + }, + blockquote: { + ...props.markdownStyles.styles.blockquote, + direction, + ...(direction === "rtl" + ? { + borderLeftWidth: 0, + borderRightWidth: props.markdownStyles.styles.blockquote?.borderLeftWidth, + borderRightColor: props.markdownStyles.styles.blockquote?.borderLeftColor, + paddingLeft: 0, + paddingRight: props.markdownStyles.styles.blockquote?.paddingLeft, + } + : null), + }, + }), + [direction, props.markdownStyles.styles], + ); + + return ( + + {props.text} + + ); +}); + /** Tap opens a link; long-press on a native file chip shows its menu. Built once per feed. */ interface MarkdownLinkHandlers { readonly onLinkPress: (href: string) => void; @@ -815,15 +878,11 @@ const AssistantMarkdownContent = memo(function AssistantMarkdownContent(props: { renderImage={props.renderImage} /> ) : ( - - {markdown} - + text={markdown} + markdownStyles={props.markdownStyles} + /> ); }); }); @@ -894,6 +953,8 @@ function MarkdownCodeBlock(props: { color: props.textColor, fontSize: props.fontSize, lineHeight: props.lineHeight, + textAlign: "left", + writingDirection: "ltr", ...(Platform.OS === "android" ? { includeFontPadding: false } : null), }} > @@ -1081,6 +1142,7 @@ function useMarkdownStyles( copyTintColor: ColorValue, preserveSoftBreaks: boolean, highlightCode: boolean, + direction: TextDirection, ): CustomRenderers => ({ link: ({ children, href = "" }) => { const presentation = resolveMarkdownLinkPresentation(href); @@ -1133,7 +1195,7 @@ function useMarkdownStyles( ); }, list: ({ node, Renderer, ordered = false, start = 1 }) => ( - + {node.children?.map((child, index) => { const childKey = `${child.type}:${child.beg ?? "unknown"}:${child.end ?? "unknown"}`; if (child.type === "task_list_item") { @@ -1147,11 +1209,12 @@ function useMarkdownStyles( className="font-sans" style={{ width: ordered ? 22 : 12, - marginRight: 5, + marginLeft: direction === "rtl" ? 5 : 0, + marginRight: direction === "rtl" ? 0 : 5, color: inlineTextColor, fontSize: markdownFontSizes.m, lineHeight: markdownFontSizes.bodyLineHeight, - textAlign: ordered ? "right" : "center", + textAlign: ordered ? (direction === "rtl" ? "left" : "right") : "center", }} > {ordered ? `${start + index}.` : "•"} @@ -1204,6 +1267,37 @@ function useMarkdownStyles( ), }); + const createDirectionalMarkdownRenderers = ( + inlineTextColor: string, + inlineCodeTextColor: string, + blockBackgroundColor: string, + blockTextColor: string, + copyTintColor: ColorValue, + preserveSoftBreaks: boolean, + highlightCode: boolean, + ): Readonly> => ({ + ltr: createMarkdownRenderers( + inlineTextColor, + inlineCodeTextColor, + blockBackgroundColor, + blockTextColor, + copyTintColor, + preserveSoftBreaks, + highlightCode, + "ltr", + ), + rtl: createMarkdownRenderers( + inlineTextColor, + inlineCodeTextColor, + blockBackgroundColor, + blockTextColor, + copyTintColor, + preserveSoftBreaks, + highlightCode, + "rtl", + ), + }); + const userTheme: PartialMarkdownTheme = { ...baseTheme, colors: { @@ -1253,7 +1347,7 @@ function useMarkdownStyles( user: { theme: userTheme, styles: userStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownUserCodeText, markdownUserInlineCodeText, markdownUserFenceBg, @@ -1287,7 +1381,7 @@ function useMarkdownStyles( assistant: { theme: assistantTheme, styles: assistantStyles, - renderers: createMarkdownRenderers( + renderers: createDirectionalMarkdownRenderers( markdownCodeText, markdownInlineCodeText, markdownCodeBg, @@ -1861,16 +1955,7 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { /> ); } - return ( - - {text} - - ); + return ; } return ( @@ -1903,15 +1988,11 @@ function LegacyUserMessageContent(props: UserMessageContentProps) { renderImage={props.renderImage} /> ) : ( - - {text} - + text={text} + markdownStyles={props.markdownStyles} + /> ); })} diff --git a/apps/mobile/src/lib/textDirection.test.ts b/apps/mobile/src/lib/textDirection.test.ts new file mode 100644 index 000000000000..5cedefc07fc3 --- /dev/null +++ b/apps/mobile/src/lib/textDirection.test.ts @@ -0,0 +1,78 @@ +import type { MarkdownNode } from "react-native-nitro-markdown"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveMarkdownNodeTextDirection, resolveTextDirection } from "./textDirection"; + +const documentWith = (...children: MarkdownNode[]): MarkdownNode => ({ + type: "document", + children, +}); + +describe("resolveTextDirection", () => { + it("resolves Hebrew and Arabic text as right-to-left", () => { + expect(resolveTextDirection("הודעה בעברית")).toBe("rtl"); + expect(resolveTextDirection("رسالة بالعربية")).toBe("rtl"); + expect(resolveTextDirection("React Server Components האם להשתמש בהם בפרויקט החדש שלנו?")).toBe( + "rtl", + ); + }); + + it("resolves English and other left-to-right scripts as left-to-right", () => { + expect(resolveTextDirection("English message")).toBe("ltr"); + expect(resolveTextDirection("日本語のメッセージ")).toBe("ltr"); + }); + + it("ignores neutral prefixes before the first letter", () => { + expect(resolveTextDirection("👋 123... שלום")).toBe("rtl"); + expect(resolveTextDirection("(123) Hello")).toBe("ltr"); + }); + + it("defaults neutral-only content to left-to-right", () => { + expect(resolveTextDirection("👋 123...")).toBe("ltr"); + }); + + it("uses parsed prose instead of code content", () => { + expect( + resolveMarkdownNodeTextDirection( + documentWith( + { type: "code_block", content: "npm test" }, + { + type: "paragraph", + children: [ + { type: "code_inline", content: "English inline code" }, + { type: "text", content: " שלום" }, + ], + }, + ), + ), + ).toBe("rtl"); + + expect( + resolveMarkdownNodeTextDirection( + documentWith({ + type: "paragraph", + children: [ + { type: "code_inline", content: "שלום" }, + { type: "text", content: " English prose" }, + ], + }), + ), + ).toBe("ltr"); + }); + + it("ignores synthesized GitHub alert markers", () => { + expect( + resolveMarkdownNodeTextDirection( + documentWith({ + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "[!NOTE] הודעת התראה בעברית." }], + }, + ], + }), + ), + ).toBe("rtl"); + }); +}); diff --git a/apps/mobile/src/lib/textDirection.ts b/apps/mobile/src/lib/textDirection.ts new file mode 100644 index 000000000000..440ba006709d --- /dev/null +++ b/apps/mobile/src/lib/textDirection.ts @@ -0,0 +1,41 @@ +import { resolveTextDirection, type TextDirection } from "@t3tools/shared/textDirection"; +import type { MarkdownNode } from "react-native-nitro-markdown"; + +const GITHUB_ALERT_MARKER = /\[!(?:NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/giu; +const MAX_MARKDOWN_PROSE_CODE_UNITS = 17_408; + +function collectMarkdownProse( + node: MarkdownNode, + chunks: string[], + remainingCodeUnits: number, +): number { + if (remainingCodeUnits === 0) return 0; + if (node.type === "code_inline" || node.type === "code_block" || node.type === "image") { + return remainingCodeUnits; + } + + if (node.type === "text") { + const text = (node.content ?? "").slice(0, remainingCodeUnits).replace(GITHUB_ALERT_MARKER, ""); + chunks.push(text); + return remainingCodeUnits - text.length; + } + + for (const child of node.children ?? []) { + remainingCodeUnits = collectMarkdownProse(child, chunks, remainingCodeUnits); + if (remainingCodeUnits === 0) break; + } + return remainingCodeUnits; +} + +function markdownProse(node: MarkdownNode): string { + const chunks: string[] = []; + collectMarkdownProse(node, chunks, MAX_MARKDOWN_PROSE_CODE_UNITS); + return chunks.join(""); +} + +export function resolveMarkdownNodeTextDirection(node: MarkdownNode): TextDirection { + return resolveTextDirection(markdownProse(node)); +} + +export { resolveTextDirection }; +export type { TextDirection }; diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index cb25e6938fa7..59ce3840672e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -515,7 +515,10 @@ describe("ChatMarkdown skill chips", () => { }); const mounted = renderer!; const labels = (label: string) => - mounted.root.findAllByType("span").filter((node) => node.children.includes(label)); + mounted.root + // The label element is a so an RTL message cannot flip the chip's text. + .findAll((node) => node.type === "span" || node.type === "bdi") + .filter((node) => node.children.includes(label)); expect(labels("2Spec")).toHaveLength(0); await act(async () => { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4a1f616aed3b..13417426bbca 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -306,6 +306,7 @@ function CodexArtifactTemplateCard(props: { data-artifact-kind={props.template.artifactKind} data-markdown-copy={`${props.template.displayName} (${presentationLabel})\n\n`} data-skill-name={props.template.skillName} + dir="auto" >
@@ -792,6 +793,7 @@ function MarkdownTable({ children, ...props }: React.ComponentProps<"table">) { ref={containerRef} className="chat-markdown-table-container" data-expanded={expanded ? "true" : "false"} + dir="ltr" > @@ -871,7 +873,7 @@ function MarkdownDetails({ data-markdown-details-open={isOpen ? "true" : "false"} >
@@ -2215,6 +2218,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } + dir="ltr" className={MARKDOWN_FILE_LINK_CLASS_NAME} data-markdown-copy={copyMarkdown} onClick={(event) => { @@ -2238,6 +2242,7 @@ const MarkdownFileLink = memo(function MarkdownFileLink({ } + dir="ltr" aria-label={`File options for ${label}`} aria-haspopup="menu" className={cn(MARKDOWN_FILE_LINK_CLASS_NAME, "select-text")} @@ -2814,10 +2819,10 @@ const CHAT_MARKDOWN_COMPONENTS = { // Not a
: the stylesheet mutes those, and an alert's body is ordinary // text under a colored title — which is how the host renders it. return ( -
+

- {alert.label} + {alert.label}

{children}
@@ -3125,7 +3130,7 @@ const CHAT_MARKDOWN_COMPONENTS = { } } return ( - + {children} ); @@ -3262,6 +3267,12 @@ const CHAT_MARKDOWN_COMPONENTS = { table: function MarkdownTableRenderer({ node: _node, ...props }) { return ; }, + th: function MarkdownTh({ node: _node, ...props }) { + return
; + }, + td: function MarkdownTd({ node: _node, ...props }) { + return ; + }, details: function MarkdownDetailsRenderer({ node: _node, children, open: detailsOpen }) { return {children}; }, @@ -3271,7 +3282,11 @@ const CHAT_MARKDOWN_COMPONENTS = { ); const codeBlock = extractCodeBlock(children); if (!codeBlock) { - return
{children}
; + return ( +
+          {children}
+        
+ ); } const language = extractFenceLanguage(codeBlock.className); @@ -3291,13 +3306,17 @@ const CHAT_MARKDOWN_COMPONENTS = { > {children}} + fallback={ +
+              {children}
+            
+ } > {/* Reserve the block's height but stay hidden until Shiki has colored it, so plain text never flashes before the highlighted version. */} +
                 {children}
               
} @@ -3354,6 +3373,7 @@ function ChatMarkdown({ "chat-markdown w-full min-w-0 text-sm leading-relaxed text-foreground/[calc(80%+var(--appearance-contrast-boost)/5)] [overflow-wrap:anywhere] [word-break:break-word]", className, )} + dir="auto" // Gates the fade-in for blocks that arrive while the response streams. data-streaming={componentState.isStreaming ? "" : undefined} onCopy={handleCopy} diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx index 9b68cd438a2b..0dda534e263f 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.test.tsx @@ -1,6 +1,8 @@ import { ApprovalRequestId } from "@t3tools/contracts"; +import { act } from "react"; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { describe, expect, it, vi } from "vite-plus/test"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import type { PendingUserInput } from "../../session-logic"; @@ -67,4 +69,82 @@ describe("ComposerPendingUserInputPanel", () => { expect(markup).toContain("Incremental"); expect(markup).toContain("Big bang"); }); + + it("auto-directs Hebrew question content with English technical terms", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("document", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); + const hebrewPrompt: PendingUserInput = { + ...prompt, + questions: [ + { + ...prompt.questions[0]!, + header: "בחירת גישה", + question: "React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", + options: [ + { label: "כן, להשתמש ב-RSC", description: "מתאים ל-Next.js App Router" }, + { label: "Client Components", description: "להשאיר את הממשק בצד הלקוח" }, + ], + }, + ], + }; + let renderer: ReactTestRenderer | undefined; + try { + await act(() => { + renderer = create( + {}} + onAdvance={() => {}} + onDismiss={() => {}} + />, + ); + }); + + for (const [text, direction] of [ + ["בחירת גישה", "rtl"], + ["React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", "rtl"], + ["כן, להשתמש ב-RSC", "rtl"], + ["מתאים ל-Next.js App Router", "rtl"], + ["Client Components", "ltr"], + ["להשאיר את הממשק בצד הלקוח", "rtl"], + ] as const) { + expect( + renderer!.root.find( + (node) => node.props.dir === direction && node.children.includes(text), + ), + ).toBeDefined(); + } + expect( + renderer!.root.findAll( + (node) => + node.type === "button" && + typeof node.props.className === "string" && + node.props.className.includes("px-2.5 py-2 text-start"), + ), + ).toHaveLength(2); + + const toggle = renderer!.root.find( + (node) => + node.type === "button" && node.props["data-pending-user-input-toggle"] === "expanded", + ); + await act(() => toggle.props.onClick({ nativeEvent: {} })); + expect( + renderer!.root.find( + (node) => + node.props.dir === "rtl" && + String(node.props.className).includes("truncate") && + node.children.includes("React Server Components האם להשתמש בהם בפרויקט החדש שלנו?"), + ), + ).toBeDefined(); + } finally { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); + } + }); }); diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index a2052c0c73fd..10bfddb72a52 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -1,4 +1,5 @@ import { type ApprovalRequestId } from "@t3tools/contracts"; +import { resolveTextDirection } from "@t3tools/shared/textDirection"; import { memo, useCallback, useEffect, useRef, useState } from "react"; import { type PendingUserInput } from "../../session-logic"; import { @@ -187,11 +188,17 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( > - + {activeQuestion.header} {isCollapsed ? ( - + {activeQuestion.question} ) : null} @@ -230,7 +237,12 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( -

{activeQuestion.question}

+

+ {activeQuestion.question} +

{activeQuestion.multiSelect ? (

Select one or more options.

) : null} @@ -245,7 +257,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( (!customAnswerActive && progress.selectedOptionValues.includes(optionValue)); const shortcutKey = index < 9 ? index + 1 : null; const className = cn( - "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-left outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25", + "group flex w-full items-center gap-2 rounded-md px-2.5 py-2 text-start outline-none transition-colors duration-150 focus-visible:ring-1 focus-visible:ring-primary/25", isSelected ? "bg-muted/55 text-foreground" : "bg-transparent text-foreground/85 hover:bg-muted/30", @@ -255,9 +267,19 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( const content = ( <>
- {option.label} + + {option.label} + {option.description && option.description !== option.label ? ( - {option.description} + + {option.description} + ) : null}
{isSelected ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index b6e5f2a2927e..4f9f5bd15417 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -1193,6 +1193,71 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); + it("resolves user and assistant message direction from each message", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup.match(/class="chat-markdown[^"]*" dir="auto"/g)).toHaveLength(4); + }); + + it("keeps code left-to-right inside auto-directed messages", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/]*dir="ltr"[^>]*class="[^"]*chat-markdown-file-link/); + expect(markup).toMatch(/]*dir="ltr"[^>]*>vp test<\/code>/); + expect(markup).toMatch(/class="chat-markdown-codeblock[^"]*"[^>]*dir="ltr"/); + }); + + it("keeps table structure left-to-right while resolving each cell direction", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toMatch(/class="chat-markdown-table-container"[^>]*dir="ltr"/); + expect(markup.match(/<(?:th|td)[^>]*dir="auto"/g)).toHaveLength(4); + }); + + it("uses logical spacing for GitHub alerts inside auto-directed messages", () => { + const markup = renderToStaticMarkup( + [!NOTE]\n> הודעת התראה בעברית.")]} + />, + ); + + expect(markup).toMatch(/
Note"); + expect(markup).not.toContain("border-l-2"); + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( @@ -1238,7 +1303,9 @@ describe("MessagesTimeline", () => { />, ); - expect(markup).toContain('<tag attr="x">'); + expect(markup).toContain( + '<tag attr="x">', + ); expect(markup).toContain("<root><child enabled="true" /></root>"); }); diff --git a/apps/web/src/components/chat/SkillInlineText.tsx b/apps/web/src/components/chat/SkillInlineText.tsx index 2327577b3a80..02afc1f9f45b 100644 --- a/apps/web/src/components/chat/SkillInlineText.tsx +++ b/apps/web/src/components/chat/SkillInlineText.tsx @@ -72,7 +72,9 @@ function SkillChip(props: { skill: InlineSkill; rawText: string }) { return ( - {formatProviderSkillDisplayName(props.skill)} + + {formatProviderSkillDisplayName(props.skill)} + ); } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index d1ca0f4cf60e..58a4db6ddbb9 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1764,7 +1764,7 @@ code { custom property, so without this a task-list under a multi-digit ordered list would inherit the outer gutter instead of its own default. */ --list-gutter: 1.25rem; - padding-left: 1.25rem; + padding-inline-start: 1.25rem; list-style-type: disc; } @@ -1775,7 +1775,7 @@ code { nested ol without its own widened marker doesn't inherit the outer one. */ .chat-markdown ol { --list-gutter: 1.25rem; - padding-left: var(--list-gutter, 1.25rem); + padding-inline-start: var(--list-gutter, 1.25rem); list-style-type: decimal; } @@ -1809,7 +1809,8 @@ code { } .chat-markdown li.task-list-item input[type="checkbox"] { - margin: 0 0.35em 0.15em calc(-1 * var(--list-gutter, 1.25rem)); + margin: 0 0 0.15em; + margin-inline: calc(-1 * var(--list-gutter, 1.25rem)) 0.35em; vertical-align: middle; } @@ -1837,8 +1838,8 @@ code { } .chat-markdown blockquote { - border-left: 2px solid var(--contrast-border); - padding-left: 0.8rem; + border-inline-start: 2px solid var(--contrast-border); + padding-inline-start: 0.8rem; color: var(--contrast-muted-foreground); } @@ -1958,7 +1959,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/packages/shared/package.json b/packages/shared/package.json index 4e04912a5d41..9cb6691cba21 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./textDirection": { + "types": "./src/textDirection.ts", + "import": "./src/textDirection.ts" + }, "./legacyCliLauncher": { "types": "./src/legacyCliLauncher.ts", "import": "./src/legacyCliLauncher.ts" diff --git a/packages/shared/src/textDirection.test.ts b/packages/shared/src/textDirection.test.ts new file mode 100644 index 000000000000..8af3fe3be11e --- /dev/null +++ b/packages/shared/src/textDirection.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveTextDirection } from "./textDirection.js"; + +describe("resolveTextDirection", () => { + it.each([ + ["العربية مع English terms", "rtl"], + ["React Server Components האם להשתמש בהם בפרויקט החדש שלנו?", "rtl"], + ["English text with one كلمة", "ltr"], + ["one two three four עברית עברית עברית עברית", "ltr"], + ["👋 123...", "ltr"], + ] as const)("resolves %s as %s", (text, direction) => { + expect(resolveTextDirection(text)).toBe(direction); + }); + + it("bounds neutral-prefix inspection", () => { + expect(resolveTextDirection(`${".".repeat(8_192)}العربية`)).toBe("ltr"); + }); +}); diff --git a/packages/shared/src/textDirection.ts b/packages/shared/src/textDirection.ts new file mode 100644 index 000000000000..36b31db36cf8 --- /dev/null +++ b/packages/shared/src/textDirection.ts @@ -0,0 +1,74 @@ +export type TextDirection = "ltr" | "rtl"; + +const LETTER_CHARACTER = /^\p{Letter}$/u; +const MARK_CHARACTER = /^\p{Mark}$/u; +const RTL_SCRIPT_CHARACTER = + /^[\u0590-\u08ff\ufb1d-\ufdff\ufe70-\ufeff\u{10800}-\u{10fff}\u{1e800}-\u{1eeff}]$/u; +// ponytail: bound streaming-render work; raise these only for real text that exceeds the sample. +const MAX_FIRST_LETTER_CODE_POINTS = 8_192; +const MAX_DIRECTION_CODE_POINTS = 512; +const MAX_DIRECTION_WORDS = 32; +const MAX_WORD_SCORE = 4; +const MAX_LEADING_LTR_WORDS = 3; +const MIN_RTL_SCORE = 8; + +export function resolveTextDirection(text: string): TextDirection { + let searchedCodePoints = 0; + let inspectedCodePoints = 0; + let fallbackStarted = false; + let wordCount = 0; + let rtlScore = 0; + let totalScore = 0; + let wordRtlLetters = 0; + let wordLtrLetters = 0; + let wordScore = 0; + let earlyRtlWordSeen = false; + + for (const character of text) { + if (!fallbackStarted) { + if (searchedCodePoints === MAX_FIRST_LETTER_CODE_POINTS) return "ltr"; + searchedCodePoints += 1; + if (!LETTER_CHARACTER.test(character)) continue; + if (RTL_SCRIPT_CHARACTER.test(character)) return "rtl"; + fallbackStarted = true; + } + + if (inspectedCodePoints === MAX_DIRECTION_CODE_POINTS || wordCount === MAX_DIRECTION_WORDS) + break; + inspectedCodePoints += 1; + + if (LETTER_CHARACTER.test(character)) { + if (RTL_SCRIPT_CHARACTER.test(character)) wordRtlLetters += 1; + else wordLtrLetters += 1; + if (wordScore < MAX_WORD_SCORE) wordScore += 1; + continue; + } + + if (MARK_CHARACTER.test(character) && wordScore !== 0) continue; + if (wordScore === 0) continue; + + wordCount += 1; + totalScore += wordScore; + if (wordRtlLetters > wordLtrLetters) { + rtlScore += wordScore; + if (wordCount <= MAX_LEADING_LTR_WORDS + 1) earlyRtlWordSeen = true; + } + if (wordCount === MAX_LEADING_LTR_WORDS + 1 && !earlyRtlWordSeen) return "ltr"; + + wordRtlLetters = 0; + wordLtrLetters = 0; + wordScore = 0; + } + + if (wordScore !== 0 && wordCount < MAX_DIRECTION_WORDS) { + totalScore += wordScore; + if (wordRtlLetters > wordLtrLetters) { + rtlScore += wordScore; + if (wordCount < MAX_LEADING_LTR_WORDS + 1) earlyRtlWordSeen = true; + } + } + + return earlyRtlWordSeen && rtlScore >= MIN_RTL_SCORE && rtlScore * 5 >= totalScore * 3 + ? "rtl" + : "ltr"; +}