// Pure helpers for MarkdownText. Kept free of React/RN deps so they can be // unit-tested directly (see markdownTextUtils.test.ts). import type { ASTNode } from 'react-native-markdown-display'; /** Only http(s) URLs are considered safe to open or to load as images. */ export function isSafeUrl(url: string): boolean { return /^https?:\/\//i.test(url.trim()); } /** GitHub-style heading slug: lowercase, drop punctuation, spaces -> hyphens. */ export function slugify(text: string): string { return text.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-'); } /** Percent-decode a URL fragment; returns the input unchanged if malformed. */ export function decodeFragment(fragment: string): string { try { return decodeURIComponent(fragment); } catch { return fragment; } } /** Plain visible text of a markdown AST node (used for heading anchor slugs). */ export function getNodeText(node?: ASTNode): string { if (!node) return ''; if (node.type === 'text' || node.type === 'code_inline') return node.content ?? ''; return (node.children ?? []).map(getNodeText).join(''); } /** All `tr` nodes under a table node, in document order. */ export function collectTableRows(node: ASTNode, acc: ASTNode[] = []): ASTNode[] { if (node.type === 'tr') acc.push(node); for (const child of node.children ?? []) collectTableRows(child, acc); return acc; } /** * Split a table AST node into its header cells and body rows' cells. A row with * any `th` cell is treated as the header; the rest are body rows. */ export function extractTableData(tableNode: ASTNode): { header: ASTNode[]; rows: ASTNode[][] } { const rows: ASTNode[][] = []; let header: ASTNode[] = []; for (const tr of collectTableRows(tableNode)) { const cells = (tr.children ?? []).filter((cell) => cell.type === 'th' || cell.type === 'td'); if (cells.some((cell) => cell.type === 'th')) header = cells; else rows.push(cells); } return { header, rows }; } // A table separator row: |---|:--:|---| (>= 2 columns, dashes with optional // alignment colons). Used to detect GFM tables. const TABLE_SEPARATOR = /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/; // Markdown image syntax: ![alt](url). const IMAGE_PATTERN = /!\[[^\]]*\]\([^)]+\)/; /** * True when the content contains a block that should be allowed to expand to * the full bubble width (and scroll horizontally): a fenced code block, a * table, or an image. Mirrors the host's `hasScrollableMarkdown` heuristic in * conversationParts so the bubble layout and the renderer agree. */ export function hasWideContent(content: string): boolean { if (!content) return false; if (/```|~~~/.test(content)) return true; if (IMAGE_PATTERN.test(content)) return true; const lines = content.replace(/\r\n/g, '\n').split('\n'); return lines.some((line, index) => line.includes('|') && TABLE_SEPARATOR.test(lines[index + 1] ?? '')); }