From ee043d6f4a774f80890957f74322e45e5220c997 Mon Sep 17 00:00:00 2001 From: Gowtham G Date: Fri, 12 Jun 2026 17:24:10 +0530 Subject: [PATCH 1/2] feat(minor): optional text selectable built using copilot-cli --- .github/copilot-instructions.md | 160 ++++++++++++++++++++ src/hooks/__tests__/useMarkdown.spec.tsx | 183 +++++++++++++++++++++++ src/hooks/useMarkdown.ts | 5 +- src/hooks/useMarkdownWithComponents.tsx | 4 +- src/lib/Markdown.tsx | 2 + src/lib/Renderer.tsx | 9 +- src/lib/__tests__/Markdown.spec.tsx | 120 +++++++++++++++ src/lib/__tests__/Renderer.spec.tsx | 47 ++++++ src/lib/types.ts | 2 + 9 files changed, 525 insertions(+), 7 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 src/hooks/__tests__/useMarkdown.spec.tsx diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..9f3bb127 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,160 @@ +# Copilot Instructions for react-native-marked + +## Build, Test, and Lint Commands + +### Setup +```bash +yarn # Install dependencies +``` + +### Development & Testing +```bash +yarn typescript # Type-check with TypeScript +yarn lint # Lint with Biome +yarn format # Auto-format with Biome +yarn test # Run all Jest tests +yarn test --watch # Run tests in watch mode +yarn test # Run specific test file (e.g., src/lib/__tests__/Markdown.spec.tsx) +``` + +### Building +```bash +yarn build # Build distribution files using react-native-builder-bob +``` + +### Example App +```bash +cd examples/react-native-marked-sample +yarn install +yarn android # Run on Android +yarn ios # Run on iOS +yarn web # Run on web +``` + +### Pre-commit Hooks +The project uses `commitlint` and `biome` pre-commit hooks (via lefthook). Commits must follow [conventional commit format](#commit-conventions). + +## High-Level Architecture + +### Core Pipeline +1. **Markdown Input** → `` component or `useMarkdown()` hook +2. **Token Generation** → `marked.js` lexer produces tokens +3. **Token Parsing** → `Parser` class converts tokens to React elements using the `Renderer` +4. **Component Rendering** → Elements are rendered in a `FlatList` (component) or returned as array (hook) + +### Key Classes & Interfaces + +**`RendererInterface`** (src/lib/types.ts) +- Defines all markdown element rendering methods (paragraph, heading, code, link, image, table, etc.) +- Implement this interface to customize how markdown elements are rendered +- Example: `class CustomRenderer extends Renderer implements RendererInterface` + +**`Renderer`** (src/lib/Renderer.tsx) +- Default implementation of `RendererInterface` +- Uses Biome slugger for ID generation across heading elements +- Handles React Native-specific rendering (ScrollView for code blocks, TouchableHighlight for links, etc.) +- See README for custom renderer examples + +**`Parser`** (src/lib/Parser.tsx) +- Converts `marked` tokens recursively into ReactNode elements +- Maintains heading styles map for h1-h6 styling +- Handles HTML entity decoding for safety + +**`Markdown` Component** (src/lib/Markdown.tsx) +- Main consumer component wrapping the `useMarkdown` hook +- Renders elements in a FlatList with lazy loading (maxToRenderPerBatch, initialNumToRender) + +### Hooks + +**`useMarkdown`** (src/hooks/useMarkdown.ts) +- Core hook that returns an array of ReactNode elements +- Accepts markdown string and options (theme, styles, renderer, baseUrl, tokenizer, hooks) +- Returns elements ready for rendering in any list component + +**`useMarkdownWithComponents`** (src/hooks/useMarkdownWithComponents.tsx) +- Extends `useMarkdown` to support embedding React components in markdown +- Requires `ReactComponentRegistryProvider` context to register components +- Parses JSX-like syntax: `` + +### Advanced Extensibility + +**Custom Tokenizer** +- Extend `marked.Tokenizer` to customize token generation (example: LaTeX support) +- Pass via `tokenizer` prop to modify markdown syntax recognition + +**Custom Renderer** +- Extend `Renderer` class and implement `RendererInterface` methods +- Override specific elements (e.g., code blocks for syntax highlighting, images for fast-image integration) +- Pass via `renderer` prop + +**Marked Hooks** +- Pass `hooks` option to transform tokens before rendering (https://marked.js.org/using_pro#hooks) + +**React Component Embedding** +- Use `ReactComponentRegistryProvider` + `useMarkdownWithComponents` to embed custom components +- Unregistered components are automatically stripped from output + +## Key Conventions + +### File Organization +- **src/lib** - Core parsing and rendering logic +- **src/components** - Reusable React Native components (MDImage, MDList, MDTable, MDSvg, etc.) +- **src/hooks** - Custom React hooks for markdown processing +- **src/theme** - Default theme, styles, and type definitions +- **src/utils** - Utility functions (URL validation, table width calculation, handlers, SVG parsing) +- **src/__tests__** - Unit tests (collocated with source files using `__tests__` directories) + +### Testing Patterns +- Tests use Jest with `@testing-library/react-native` +- Snapshot testing for component output (`expect(tree).toMatchSnapshot()`) +- Test files named `*.spec.ts(x)` or `*.test.ts(x)` +- Run full test suite: `yarn test` +- Run single test file: `yarn test src/lib/__tests__/Markdown.spec.tsx` +- Update snapshots: `yarn test:updateSnapshot` + +### TypeScript & Types +- All public APIs are fully typed +- Type exports: `RendererInterface`, `MarkdownProps`, `UserTheme`, `MarkedStyles`, `ParserOptions` +- Generic support for custom tokens: `MarkedTokenizer` + +### Styling & Theming +- Theme system defines colors, spacing, and typography via `UserTheme` interface +- Component styles override theme values via `MarkedStyles` interface +- Default light/dark themes based on `colorScheme` prop +- Styles accept standard React Native style objects (ViewStyle, TextStyle, ImageStyle) + +### Commit Message Format +Follow [Conventional Commits](https://www.conventionalcommits.org/): +- `feat:` New feature +- `fix:` Bug fix +- `refactor:` Code refactor +- `docs:` Documentation changes +- `test:` Test additions/changes +- `chore:` Build, CI, dependencies, tooling +- `perf:` Performance improvements + +Pre-commit hooks verify format automatically. + +### Code Quality Tools +- **Biome** - Unified linter and formatter (replaces ESLint + Prettier) + - Config: `biome.json` + - Rules: Recommended set with customizations (e.g., `noArrayIndexKey: "info"`) +- **TypeScript** - Strict type checking +- **Jest** - Unit testing with jsdom environment for React Native Web compatibility +- **release-it** - Automated npm publishing with semantic versioning + +### Exported Public APIs +Check `src/index.ts` for exports: +- `Markdown` - Main component +- `useMarkdown` - Hook for elements array +- `useMarkdownWithComponents` - Hook with component support +- `ReactComponentRegistryProvider` - Context provider +- `Renderer` - Base class for custom renderers +- `Parser` - Token to element converter +- Type exports: `RendererInterface`, `MarkdownProps`, `MarkedTokenizer`, etc. + +## Performance Considerations + +- **FlatList Optimization** - Component uses `removeClippedSubviews={false}`, `maxToRenderPerBatch`, and `initialNumToRender` for large markdown documents +- **Reassure Tests** - Performance regression detection (run with `yarn reassure`) +- **Snapshot Performance** - See `src/lib/__perf__/` for performance testing examples diff --git a/src/hooks/__tests__/useMarkdown.spec.tsx b/src/hooks/__tests__/useMarkdown.spec.tsx new file mode 100644 index 00000000..c5eb4882 --- /dev/null +++ b/src/hooks/__tests__/useMarkdown.spec.tsx @@ -0,0 +1,183 @@ +import { render } from "@testing-library/react-native"; +import type { ReactElement } from "react"; +import React from "react"; +import useMarkdown from "../useMarkdown"; + +describe("useMarkdown hook", () => { + describe("selectableText option", () => { + it("renders with selectable=true by default", () => { + const TestComponent = () => { + const elements = useMarkdown("Hello world", { colorScheme: "light" }); + return elements[0] as ReactElement; + }; + + const r = render(); + const tree = r.toJSON(); + + // Check that Text elements have selectable=true + const findSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === true + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => findSelectableText(child)); + } + if (node.children) { + return findSelectableText(node.children); + } + return false; + }; + + expect(findSelectableText(tree)).toBe(true); + }); + + it("renders with selectable=false when selectableText option is false", () => { + const TestComponent = () => { + const elements = useMarkdown("Hello world", { + colorScheme: "light", + selectableText: false, + }); + return elements[0] as ReactElement; + }; + + const r = render(); + const tree = r.toJSON(); + + // Check that Text elements have selectable=false + const findNonSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === false + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => + findNonSelectableText(child), + ); + } + if (node.children) { + return findNonSelectableText(node.children); + } + return false; + }; + + expect(findNonSelectableText(tree)).toBe(true); + }); + + it("renders with selectable=true when selectableText option is explicitly true", () => { + const TestComponent = () => { + const elements = useMarkdown("Hello world", { + colorScheme: "light", + selectableText: true, + }); + return elements[0] as ReactElement; + }; + + const r = render(); + const tree = r.toJSON(); + + // Check that Text elements have selectable=true + const findSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === true + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => findSelectableText(child)); + } + if (node.children) { + return findSelectableText(node.children); + } + return false; + }; + + expect(findSelectableText(tree)).toBe(true); + }); + + it("renders links with selectable=false when selectableText option is false", () => { + const TestComponent = () => { + const elements = useMarkdown("[Link](https://example.com)", { + colorScheme: "light", + selectableText: false, + }); + return elements[0] as ReactElement; + }; + + const r = render(); + const tree = r.toJSON(); + + // Check that Text elements have selectable=false + const findNonSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === false + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => + findNonSelectableText(child), + ); + } + if (node.children) { + return findNonSelectableText(node.children); + } + return false; + }; + + expect(findNonSelectableText(tree)).toBe(true); + }); + + it("works with custom renderer and selectableText=false", () => { + const TestComponent = () => { + const elements = useMarkdown("Hello world", { + colorScheme: "light", + selectableText: false, + }); + return elements[0] as ReactElement; + }; + + const r = render(); + const tree = r.toJSON(); + + expect(tree).toBeTruthy(); + + // Verify that the custom selectableText option is respected + const findNonSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === false + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => + findNonSelectableText(child), + ); + } + if (node.children) { + return findNonSelectableText(node.children); + } + return false; + }; + + expect(findNonSelectableText(tree)).toBe(true); + }); + }); +}); diff --git a/src/hooks/useMarkdown.ts b/src/hooks/useMarkdown.ts index cb96ab4e..3687d06f 100644 --- a/src/hooks/useMarkdown.ts +++ b/src/hooks/useMarkdown.ts @@ -15,6 +15,7 @@ export interface useMarkdownHookOptions { baseUrl?: string; tokenizer?: Tokenizer; hooks?: Hooks; + selectableText?: boolean; } const useMarkdown = ( @@ -31,9 +32,9 @@ const useMarkdown = ( new Parser({ styles: styles, baseUrl: options?.baseUrl, - renderer: options?.renderer ?? new Renderer(), + renderer: options?.renderer ?? new Renderer(options?.selectableText), }), - [options?.renderer, options?.baseUrl, styles], + [options?.renderer, options?.baseUrl, options?.selectableText, styles], ); const elements = useMemo(() => { diff --git a/src/hooks/useMarkdownWithComponents.tsx b/src/hooks/useMarkdownWithComponents.tsx index ca724859..6e401bc4 100644 --- a/src/hooks/useMarkdownWithComponents.tsx +++ b/src/hooks/useMarkdownWithComponents.tsx @@ -46,8 +46,8 @@ export function useMarkdownWithComponents( }, []); const baseRenderer = useMemo( - () => options?.renderer ?? new Renderer(), - [options?.renderer], + () => options?.renderer ?? new Renderer(options?.selectableText), + [options?.renderer, options?.selectableText], ); const renderer = useMemo(() => { diff --git a/src/lib/Markdown.tsx b/src/lib/Markdown.tsx index 40aa772a..1d49a7ec 100644 --- a/src/lib/Markdown.tsx +++ b/src/lib/Markdown.tsx @@ -12,6 +12,7 @@ const Markdown = ({ styles, tokenizer, hooks, + selectableText, }: MarkdownProps) => { const colorScheme = useColorScheme(); @@ -23,6 +24,7 @@ const Markdown = ({ styles, tokenizer, hooks, + selectableText, }); const renderItem = useCallback(({ item }: { item: ReactNode }) => { diff --git a/src/lib/Renderer.tsx b/src/lib/Renderer.tsx index bc6f374b..66dc8b0d 100644 --- a/src/lib/Renderer.tsx +++ b/src/lib/Renderer.tsx @@ -22,8 +22,11 @@ class Renderer implements RendererInterface { private slugPrefix = "react-native-marked-ele"; private slugger: Slugger; private windowWidth: number; - constructor() { + private selectableText: boolean; + + constructor(selectableText: boolean = true) { this.slugger = new Slugger(); + this.selectableText = selectableText; const { width } = Dimensions.get("window"); this.windowWidth = width; } @@ -101,7 +104,7 @@ class Renderer implements RendererInterface { ): ReactNode { return ( + {children} ); diff --git a/src/lib/__tests__/Markdown.spec.tsx b/src/lib/__tests__/Markdown.spec.tsx index 638381b6..f9ff4abb 100644 --- a/src/lib/__tests__/Markdown.spec.tsx +++ b/src/lib/__tests__/Markdown.spec.tsx @@ -907,4 +907,124 @@ describe("Hooks", () => { expect(screen.queryByText("Hello")).toBeTruthy(); expect(screen.queryByText("$$world$$")).toBeTruthy(); }); + + describe("selectableText prop", () => { + it("renders with selectable=true by default", () => { + const r = render(); + const tree = r.toJSON(); + + expect(screen.queryByText("Hello world")).toBeTruthy(); + + // Check that Text elements have selectable=true + const findSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === true + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => findSelectableText(child)); + } + if (node.children) { + return findSelectableText(node.children); + } + return false; + }; + + expect(findSelectableText(tree)).toBe(true); + }); + + it("renders with selectable=false when selectableText prop is false", () => { + const r = render(); + const tree = r.toJSON(); + + expect(screen.queryByText("Hello world")).toBeTruthy(); + + // Check that Text elements have selectable=false + const findNonSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === false + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => + findNonSelectableText(child), + ); + } + if (node.children) { + return findNonSelectableText(node.children); + } + return false; + }; + + expect(findNonSelectableText(tree)).toBe(true); + }); + + it("renders with selectable=true when selectableText prop is explicitly true", () => { + const r = render(); + const tree = r.toJSON(); + + expect(screen.queryByText("Hello world")).toBeTruthy(); + + // Check that Text elements have selectable=true + const findSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === true + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => findSelectableText(child)); + } + if (node.children) { + return findSelectableText(node.children); + } + return false; + }; + + expect(findSelectableText(tree)).toBe(true); + }); + + it("renders links with selectable=false when selectableText prop is false", () => { + const r = render( + , + ); + const tree = r.toJSON(); + + expect(screen.queryByText("Link")).toBeTruthy(); + + // Check that Text elements for links have selectable=false + const findNonSelectableText = (node: any): boolean => { + if (!node) return false; + if ( + node.type === "Text" && + node.props && + node.props.selectable === false + ) { + return true; + } + if (Array.isArray(node.children)) { + return node.children.some((child: any) => + findNonSelectableText(child), + ); + } + if (node.children) { + return findNonSelectableText(node.children); + } + return false; + }; + + expect(findNonSelectableText(tree)).toBe(true); + }); + }); }); diff --git a/src/lib/__tests__/Renderer.spec.tsx b/src/lib/__tests__/Renderer.spec.tsx index 75b6f2b7..f3e31554 100644 --- a/src/lib/__tests__/Renderer.spec.tsx +++ b/src/lib/__tests__/Renderer.spec.tsx @@ -244,3 +244,50 @@ describe("Renderer", () => { }); } }); + +describe("selectableText prop", () => { + it("renders Text nodes with selectable=true by default", () => { + const defaultRenderer = new Renderer(); + const TextNode = defaultRenderer.text("Hello world"); + + const r = render(TextNode as ReactElement); + const tree = r.toJSON(); + expect(tree).toHaveProperty("props.selectable", true); + }); + + it("renders Text nodes with selectable=false when selectableText is false", () => { + const nonSelectableRenderer = new Renderer(false); + const TextNode = nonSelectableRenderer.text("Hello world"); + + const r = render(TextNode as ReactElement); + const tree = r.toJSON(); + expect(tree).toHaveProperty("props.selectable", false); + }); + + it("renders Text nodes with selectable=true when selectableText is explicitly true", () => { + const selectableRenderer = new Renderer(true); + const TextNode = selectableRenderer.text("Hello world"); + + const r = render(TextNode as ReactElement); + const tree = r.toJSON(); + expect(tree).toHaveProperty("props.selectable", true); + }); + + it("renders Link nodes with selectable=false when selectableText is false", () => { + const nonSelectableRenderer = new Renderer(false); + const LinkNode = nonSelectableRenderer.link("Link", "https://example.com"); + + const r = render(LinkNode as ReactElement); + const tree = r.toJSON(); + expect(tree).toHaveProperty("props.selectable", false); + }); + + it("renders Link nodes with selectable=true by default", () => { + const defaultRenderer = new Renderer(); + const LinkNode = defaultRenderer.link("Link", "https://example.com"); + + const r = render(LinkNode as ReactElement); + const tree = r.toJSON(); + expect(tree).toHaveProperty("props.selectable", true); + }); +}); diff --git a/src/lib/types.ts b/src/lib/types.ts index 24e2119d..58cc0c65 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,7 @@ export interface ParserOptions { styles?: MarkedStyles; baseUrl?: string; renderer: RendererInterface; + selectableText?: boolean; } export interface MarkdownProps extends Partial { @@ -23,6 +24,7 @@ export interface MarkdownProps extends Partial { theme?: UserTheme; tokenizer?: Tokenizer; hooks?: Hooks; + selectableText?: boolean; } export type TableColAlignment = "center" | "left" | "right" | null; From 2c5804f34ae5022c2089e510f7d8d5b5e6baa308 Mon Sep 17 00:00:00 2001 From: Gowtham G Date: Fri, 12 Jun 2026 17:30:27 +0530 Subject: [PATCH 2/2] docs: update readme with selectableText prop --- README.md | 39 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 385c3a04..c72c03cb 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ export default ExampleComponent; | baseUrl | A prefix url for any relative link | string | true | | renderer | Custom component Renderer | [RendererInterface](src/lib/types.ts) | true | | hooks | Hooks run during parsing to transform tokens | [Marked Hooks](https://marked.js.org/using_pro#hooks) | true | +| selectableText| Whether text elements should be selectable (default: true) | boolean | true | ### Using hook @@ -91,8 +92,8 @@ const CustomComponent = () => { | baseUrl | A prefix url for any relative link | string | true | | renderer | Custom component Renderer | [RendererInterface](src/lib/types.ts) | true | | tokenizer | Generate custom tokens | [MarkedTokenizer](src/lib/types.ts) | true | -| hooks | Hooks run during parsing to transform tokens | -[Marked Hooks](https://marked.js.org/using_pro#hooks) | true | +| hooks | Hooks run during parsing to transform tokens | [Marked Hooks](https://marked.js.org/using_pro#hooks) | true | +| selectableText | Whether text elements should be selectable (default: true) | boolean | true | ## Examples @@ -119,6 +120,40 @@ Ref: [CommonMark](https://commonmark.org/help/) > HTML will be treated as plain text. Please refer [issue#290](https://github.com/gmsgowtham/react-native-marked/issues/290) for a potential solution +## Text Selection + +By default, all text elements are selectable. You can disable text selection by setting the `selectableText` prop to `false`: + +```tsx +import Markdown from "react-native-marked"; + +// Component - disable text selection +const ExampleComponent = () => { + return ( + + ); +}; + +// Hook - disable text selection +const ExampleWithHook = () => { + const elements = useMarkdown("# Hello world", { + colorScheme: "light", + selectableText: false, + }); + + return ( + + {elements.map((element, index) => ( + {element} + ))} + + ); +}; +``` + ## Advanced ### Embedding React Components in Markdown