Condition: {weather.current?.condition?.text}
Wind: {weather.current?.wind_kph} km/h
Humidity: {weather.current?.humidity}%
Pressure: {weather.current?.pressure_mb} mb
);
diff --git a/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts b/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
index f918578..39cf438 100644
--- a/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
+++ b/examples/nextjs-ai-chatbot/hooks/usetranscribe.ts
@@ -112,7 +112,6 @@ export function useTranscriber(options: Options = {}) {
mr.onstop = handleStop;
mr.start(250);
}
-
} catch (e: any) {
setError(e?.message || 'Something went wrong');
console.error(e);
@@ -185,7 +184,6 @@ export function useTranscriber(options: Options = {}) {
});
streamRef.current = stream;
const ctx = new (window.AudioContext ||
-
(window as any).webkitAudioContext)();
audioCtxRef.current = ctx;
@@ -216,7 +214,6 @@ export function useTranscriber(options: Options = {}) {
recordingStartedAtRef.current = performance.now();
rafRef.current = requestAnimationFrame(checkSilence);
-
} catch (e: any) {
setError(e?.message || 'Mic access failed');
console.error(e);
diff --git a/examples/react-example/src/App.tsx b/examples/react-example/src/App.tsx
index 310a759..d0aac25 100644
--- a/examples/react-example/src/App.tsx
+++ b/examples/react-example/src/App.tsx
@@ -9,15 +9,15 @@ function App() {
return (
<>
+
@@ -25,7 +25,7 @@ function App() {
Edit src/App.tsx and save to test HMR
-
+
Click on the Vite and React logos to learn more
>
diff --git a/packages/react-native-sdk/package.json b/packages/react-native-sdk/package.json
index fbd98bd..ec1c077 100644
--- a/packages/react-native-sdk/package.json
+++ b/packages/react-native-sdk/package.json
@@ -45,13 +45,13 @@
"@khanacademy/simple-markdown": "^2.1.0",
"linkifyjs": "^4.3.2",
"lodash": "4.17.21",
- "react-native-syntax-highlighter": "^2.1.0",
"react-syntax-highlighter": "15.5.0"
},
"devDependencies": {
"@types/lodash": "4.17.20",
"@types/node": "^24",
"@types/react": "19.2.2",
+ "@types/react-syntax-highlighter": "^15.5.13",
"concurrently": "catalog:",
"react": "19.2.0",
"react-native": "^0.82.1",
@@ -68,7 +68,8 @@
"react-native": ">=0.73.0",
"react-native-gesture-handler": ">=2.18.0",
"react-native-reanimated": ">=3.16.0",
- "react-native-svg": ">=15.8.0"
+ "react-native-svg": ">=15.8.0",
+ "react-syntax-highlighter": ">=15.0.0"
},
"peerDependenciesMeta": {
"expo": {
diff --git a/packages/react-native-sdk/src/index.ts b/packages/react-native-sdk/src/index.ts
index f666465..8566a6a 100644
--- a/packages/react-native-sdk/src/index.ts
+++ b/packages/react-native-sdk/src/index.ts
@@ -1,2 +1,3 @@
export * from './markdown';
export * from './MarkdownRichText';
+export * from './syntax-highlighting';
diff --git a/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx b/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
index d5a9efc..693f0e0 100644
--- a/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
+++ b/packages/react-native-sdk/src/markdown/components/CodeBlock.tsx
@@ -1,8 +1,7 @@
import { Pressable, type PressableProps, Text, View } from 'react-native';
import type { MarkdownComponentProps, RuleRenderFunction } from '../types.ts';
import { MarkdownReactiveScrollView } from '../../components';
-// @ts-expect-error need to check what's up with the lib
-import SyntaxHighlighter from 'react-native-syntax-highlighter';
+import SyntaxHighlighter from '../../syntax-highlighting/SyntaxHighlighter.tsx';
import { type PropsWithChildren, useCallback, useMemo } from 'react';
export const CodeBlockCopyButton = ({
diff --git a/packages/react-native-sdk/src/markdown/components/List.tsx b/packages/react-native-sdk/src/markdown/components/List.tsx
index 84e0047..d6a4c64 100644
--- a/packages/react-native-sdk/src/markdown/components/List.tsx
+++ b/packages/react-native-sdk/src/markdown/components/List.tsx
@@ -23,7 +23,7 @@ export const List = ({
if (item === null) {
return (
-
+
+
{
+ stylesheet = Array.isArray(stylesheet) ? stylesheet[0] : stylesheet;
+
+ const transformedStyle = Object.entries(stylesheet ?? {}).reduce(
+ (newStylesheet, [className, style]) => {
+ const rn = cssToRNTextStyle(style);
+
+ newStylesheet[className] = rn as RNStyle;
+ return newStylesheet;
+ },
+ {},
+ );
+
+ const topLevel = (
+ highlighter === 'prism'
+ ? transformedStyle['pre[class*="language-"]']
+ : transformedStyle.hljs
+ ) as RNStyle;
+
+ const defaultColor = (topLevel && topLevel.color) || '#000';
+
+ return { transformedStyle, defaultColor };
+};
+
+const createChildren = ({
+ stylesheet,
+ fontSize,
+ fontFamily,
+}: {
+ stylesheet: SyntaxHighlighterStylesheet;
+ fontSize?: number;
+ fontFamily?: string;
+}) => {
+ let childrenCount = 0;
+ return (children: rendererNode['children'], defaultColor: string) => {
+ childrenCount += 1;
+ return (children ?? []).map((child, i) =>
+ createNativeElement({
+ node: child,
+ stylesheet,
+ key: `code-segment-${childrenCount}-${i}`,
+ defaultColor,
+ fontSize,
+ fontFamily,
+ }),
+ );
+ };
+};
+
+const createNativeElement = ({
+ node,
+ stylesheet,
+ key,
+ defaultColor,
+ fontFamily,
+ fontSize = DEFAULT_FONT_SIZE,
+}: {
+ node: rendererNode;
+ stylesheet: SyntaxHighlighterStylesheet;
+ key: string;
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize?: number;
+}) => {
+ const { properties, type, tagName: TagName, value } = node;
+ const startingStyle = { fontFamily, fontSize, height: fontSize + 5 };
+ if (type === 'text') {
+ return (
+
+ {value}
+
+ );
+ } else if (TagName) {
+ const childrenFactory = createChildren({
+ stylesheet,
+ fontSize,
+ fontFamily,
+ });
+ const style = properties
+ ? createStyleObject(
+ properties.className,
+ Object.assign(
+ { color: defaultColor },
+ properties.style,
+ startingStyle,
+ ),
+ stylesheet,
+ )
+ : {};
+ const children = childrenFactory(
+ node.children,
+ style.color || defaultColor,
+ );
+ return (
+
+ {children}
+
+ );
+ }
+};
+
+const nativeRenderer = ({
+ defaultColor,
+ fontFamily,
+ fontSize,
+}: {
+ defaultColor: string;
+ fontFamily?: string;
+ fontSize?: number;
+}): SyntaxHighlighterProps['renderer'] => {
+ return ({ rows, stylesheet }) =>
+ rows.map((node, i) =>
+ createNativeElement({
+ node,
+ stylesheet,
+ key: `code-segment-${i}`,
+ defaultColor,
+ fontFamily,
+ fontSize,
+ }),
+ );
+};
+
+const NativeSyntaxHighlighter = ({
+ fontFamily = Platform.OS === 'ios' ? 'Courier' : 'Monospace',
+ fontSize = DEFAULT_FONT_SIZE,
+ children,
+ highlighter = 'highlightjs',
+ style = highlighter === 'prism' ? prismDefaultStyle : defaultStyle,
+ PreTag = MarkdownReactiveScrollView,
+ CodeTag = MarkdownReactiveScrollView,
+ ...rest
+}: PropsWithChildren) => {
+ const { transformedStyle, defaultColor } = useMemo(
+ () =>
+ generateNewStylesheet({
+ stylesheet: style,
+ highlighter,
+ }),
+ [highlighter, style],
+ );
+ const renderer = useMemo(
+ () =>
+ nativeRenderer({
+ defaultColor: defaultColor as string,
+ fontFamily,
+ fontSize,
+ }),
+ [defaultColor, fontFamily, fontSize],
+ );
+
+ const Highlighter =
+ highlighter === 'prism' ? SyntaxHighlighterPrism : SyntaxHighlighter;
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default NativeSyntaxHighlighter;
diff --git a/packages/react-native-sdk/src/syntax-highlighting/converter.ts b/packages/react-native-sdk/src/syntax-highlighting/converter.ts
new file mode 100644
index 0000000..5aadacb
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/converter.ts
@@ -0,0 +1,242 @@
+import type { TextStyle } from 'react-native';
+import type { ConvertOpts, CSSP } from './types.ts';
+
+const EM_DEFAULT = 16;
+
+/** Parse CSS lengths like 14, "14", "14px", "1.25em", "0.875rem" */
+const len = (
+ v: unknown,
+ { baseFontSize, rootFontSize }: ConvertOpts,
+): number | undefined => {
+ if (v == null) return undefined;
+ if (typeof v === 'number') return v;
+ if (typeof v !== 'string') return undefined;
+
+ const s = v.trim().toLowerCase();
+ if (s.endsWith('px')) {
+ const n = Number(s.slice(0, -2));
+ return Number.isFinite(n) ? n : undefined;
+ }
+ if (s.endsWith('em')) {
+ const n = Number(s.slice(0, -2));
+ return Number.isFinite(n) ? n * (baseFontSize ?? EM_DEFAULT) : undefined;
+ }
+ if (s.endsWith('rem')) {
+ const n = Number(s.slice(0, -3));
+ const root = rootFontSize ?? baseFontSize ?? EM_DEFAULT;
+ return Number.isFinite(n) ? n * root : undefined;
+ }
+ // plain number-like string
+ const n = Number(s);
+ return Number.isFinite(n) ? n : undefined;
+};
+
+/** Font shorthand: e.g. "italic small-caps 700 14px/20px Menlo, monospace" */
+const parseFontShorthand = (v: string, opts: ConvertOpts) => {
+ const out: Partial = {};
+ const parts = v.split(/\s+/);
+ // Very light parser: pick out style, weight, size[/lineHeight], and family
+ // Strategy:
+ // - style: "normal|italic"
+ // - weight: "normal|bold|100..900"
+ // - when we hit a token like "14px" or "14px/20px" → size & optional lineHeight
+ // - everything after size token is font family (may contain commas/spaces)
+ let i = 0;
+
+ // style
+ if (parts[i] === 'italic' || parts[i] === 'normal') {
+ if (parts[i] !== 'normal') out.fontStyle = 'italic';
+ i++;
+ }
+
+ // (skip small-caps if present; RN uses fontVariant which is separate)
+ if (parts[i] === 'small-caps') {
+ out.fontVariant = ['small-caps'];
+ i++;
+ }
+
+ // weight
+ if (/^(normal|bold|[1-9]00)$/.test(parts[i] ?? '')) {
+ const token = parts[i]!;
+ out.fontWeight = (
+ token === 'normal' ? undefined : token
+ ) as TextStyle['fontWeight'];
+ i++;
+ }
+
+ // size[/lineHeight]
+ const sizeToken = parts[i];
+ if (
+ sizeToken &&
+ (/.+(px|em|rem)$/.test(sizeToken) ||
+ /^[\d.]+(\/[\d.]+)?(px|em|rem)?$/.test(sizeToken))
+ ) {
+ const [fs, lh] = sizeToken.split('/');
+ const fontSize = len(fs, opts);
+ if (fontSize != null) out.fontSize = fontSize;
+ if (lh) {
+ const lineHeight = len(lh, opts);
+ if (lineHeight != null) out.lineHeight = lineHeight;
+ }
+ i++;
+ }
+
+ // family
+ const family = parts.slice(i).join(' ').replace(/^,|,$/g, '').trim();
+ if (family) {
+ // basic cleanup: take first family; strip quotes
+ const first = family
+ .split(',')[0]
+ ?.trim()
+ .replace(/^["']|["']$/g, '');
+ if (first) out.fontFamily = first;
+ }
+
+ return out;
+};
+
+/** text-decoration shorthand → RN textDecorationLine/Color/Style */
+const applyTextDecoration = (v: unknown, acc: Partial) => {
+ if (typeof v !== 'string') return;
+ const tokens = v.toLowerCase().split(/\s+/);
+ let line: TextStyle['textDecorationLine'] | undefined = undefined;
+ let style: TextStyle['textDecorationStyle'] | undefined;
+ let color: TextStyle['textDecorationColor'] | undefined;
+
+ for (const t of tokens) {
+ if (t === 'none') line = 'none';
+ else if (t === 'underline')
+ line = line === 'line-through' ? 'underline line-through' : 'underline';
+ else if (t === 'line-through')
+ line = line === 'underline' ? 'underline line-through' : 'line-through';
+ else if (
+ t === 'solid' ||
+ t === 'double' ||
+ t === 'dotted' ||
+ t === 'dashed'
+ )
+ style = t;
+ // we take a naive approach and treat this as a color token; RN accepts CSS color strings
+ else if (t) color = t;
+ }
+
+ if (line) acc.textDecorationLine = line;
+ if (style) acc.textDecorationStyle = style;
+ if (color) acc.textDecorationColor = color;
+};
+
+/** text-shadow CSS → RN textShadowColor/Offset/Radius
+ * for example: "1px 1px #000", "1px 1px 2px rgba(0,0,0,0.4)"
+ */
+const applyTextShadow = (
+ v: unknown,
+ acc: Partial,
+ opts: ConvertOpts,
+) => {
+ if (typeof v !== 'string') return;
+ const parts = v.trim().split(/\s+/);
+ if (parts.length < 2) return;
+
+ const ox = len(parts[0], opts);
+ const oy = len(parts[1], opts);
+
+ let radius: number | undefined;
+ let color: string | undefined;
+
+ if (parts[2]) {
+ const r = len(parts[2], opts);
+ if (Number.isFinite(r!)) {
+ radius = r!;
+ color = parts.slice(3).join(' ');
+ } else {
+ color = parts.slice(2).join(' ');
+ }
+ }
+
+ if (color) acc.textShadowColor = color;
+ acc.textShadowOffset = { width: ox ?? 0, height: oy ?? 0 };
+ if (radius != null) acc.textShadowRadius = radius;
+};
+
+/** map CSS (web) to RN TextStyle */
+export const cssToRNTextStyle = (
+ css: Partial,
+ options: ConvertOpts = {},
+): TextStyle => {
+ const opts: ConvertOpts = {
+ baseFontSize: options.baseFontSize ?? EM_DEFAULT,
+ rootFontSize: options.rootFontSize ?? options.baseFontSize ?? EM_DEFAULT,
+ };
+
+ const out: Partial = {};
+
+ // Simple 1:1 or unit-converted mappings
+ if (css.color) out.color = String(css.color);
+
+ if (css.background || css.backgroundColor) {
+ // RN ignores complex backgrounds; pass solid color if present
+ if (typeof css.backgroundColor === 'string')
+ out.backgroundColor = css.backgroundColor;
+ else if (typeof css.background === 'string')
+ out.backgroundColor = css.background;
+ }
+
+ if (css.fontFamily) out.fontFamily = String(css.fontFamily);
+ if (css.fontStyle) out.fontStyle = css.fontStyle as TextStyle['fontStyle'];
+ if (css.fontWeight)
+ out.fontWeight = String(css.fontWeight) as TextStyle['fontWeight'];
+
+ if (css.fontSize != null) {
+ const n = len(css.fontSize, opts);
+ if (n != null) out.fontSize = n;
+ }
+
+ if (css.lineHeight != null) {
+ const n = len(css.lineHeight, opts);
+ if (n != null) out.lineHeight = n;
+ }
+
+ if (css.letterSpacing != null) {
+ const n = len(css.letterSpacing, opts);
+ if (n != null) out.letterSpacing = n;
+ }
+
+ if (css.textAlign) out.textAlign = css.textAlign as TextStyle['textAlign'];
+ if (css.textTransform)
+ out.textTransform = css.textTransform as TextStyle['textTransform'];
+
+ if (css.font && typeof css.font === 'string') {
+ Object.assign(out, parseFontShorthand(css.font, opts));
+ }
+
+ if (css.textDecoration) {
+ applyTextDecoration(css.textDecoration, out);
+ }
+ if (css.textDecorationLine) {
+ out.textDecorationLine =
+ css.textDecorationLine as TextStyle['textDecorationLine'];
+ }
+ if (css.textDecorationColor) {
+ out.textDecorationColor = String(css.textDecorationColor);
+ }
+ if (css.textDecorationStyle) {
+ out.textDecorationStyle =
+ css.textDecorationStyle as TextStyle['textDecorationStyle'];
+ }
+
+ if (css.textShadow) applyTextShadow(css.textShadow, out, opts);
+
+ // Simulating the closest we can get to a CSS overflow-like functionality
+ const overflowLike =
+ (css as any).overflow ?? (css as any).overflowX ?? (css as any).overflowY;
+ if (typeof overflowLike === 'string') {
+ out.overflow =
+ overflowLike === 'hidden'
+ ? 'hidden'
+ : overflowLike === 'scroll' || overflowLike === 'auto'
+ ? 'scroll'
+ : 'visible';
+ }
+
+ return out as TextStyle;
+};
diff --git a/packages/react-native-sdk/src/syntax-highlighting/index.ts b/packages/react-native-sdk/src/syntax-highlighting/index.ts
new file mode 100644
index 0000000..3e11996
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/index.ts
@@ -0,0 +1,11 @@
+/**
+ * This sub-library is a port/took inspiration from https://github.com/conorhastings/react-native-syntax-highlighter.
+ * We create our own since we don't want the version of https://github.com/react-syntax-highlighter/react-syntax-highlighter
+ * to be pinned to version 6 but rather use whatever we see fit.
+ */
+
+export * from './SyntaxHighlighter';
+export * from './prism-config';
+export * from './converter';
+
+export * from './types';
diff --git a/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts b/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts
new file mode 100644
index 0000000..c83c126
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/prism-config.ts
@@ -0,0 +1,2 @@
+// @ts-expect-error This negates the global variable set by react-syntax-highlighter
+global.Prism = { disableWorkerMessageHandler: true };
diff --git a/packages/react-native-sdk/src/syntax-highlighting/types.ts b/packages/react-native-sdk/src/syntax-highlighting/types.ts
new file mode 100644
index 0000000..5ad2f04
--- /dev/null
+++ b/packages/react-native-sdk/src/syntax-highlighting/types.ts
@@ -0,0 +1,29 @@
+import type { TextStyle } from 'react-native';
+import type { SyntaxHighlighterProps } from 'react-syntax-highlighter';
+import type React from 'react';
+
+export type RNStyle = TextStyle;
+export type RNSheet = Record;
+
+export type SyntaxHighlighterStylesheet = NonNullable<
+ NativeSyntaxHighlighterProps['style']
+>;
+
+export type ExtraSyntaxHighlighterProps = {
+ fontFamily?: string;
+ fontSize?: number;
+ highlighter?: 'highlightjs' | 'prism';
+};
+
+export type NativeSyntaxHighlighterProps = SyntaxHighlighterProps &
+ ExtraSyntaxHighlighterProps;
+
+export type CSSP = React.CSSProperties;
+
+/** Options for unit conversion */
+export type ConvertOpts = {
+ /** base for `em` (defaults to 16) */
+ baseFontSize?: number;
+ /** root base for `rem` (defaults to baseFontSize) */
+ rootFontSize?: number;
+};
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 93c9860..01efffc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -221,9 +221,6 @@ importers:
react-native-svg:
specifier: '>=15.8.0'
version: 15.14.0(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0)
- react-native-syntax-highlighter:
- specifier: ^2.1.0
- version: 2.1.0(react-syntax-highlighter@15.5.0(react@19.2.0))
react-syntax-highlighter:
specifier: 15.5.0
version: 15.5.0(react@19.2.0)
@@ -237,6 +234,9 @@ importers:
'@types/react':
specifier: 19.2.2
version: 19.2.2
+ '@types/react-syntax-highlighter':
+ specifier: ^15.5.13
+ version: 15.5.13
concurrently:
specifier: 'catalog:'
version: 9.2.1
@@ -5122,11 +5122,6 @@ packages:
react: '*'
react-native: '*'
- react-native-syntax-highlighter@2.1.0:
- resolution: {integrity: sha512-upu8gpKT2ZeslXn2d763KwtzzhM9OUHGgJjIKKIUw1JnFAzVwQmKCaFGoI6PkQa7T1LVggBW5k5VoaLFhZDb+g==}
- peerDependencies:
- react-syntax-highlighter: ^6.0.4
-
react-native-worklets@0.5.2:
resolution: {integrity: sha512-lCzmuIPAK/UaOJYEPgYpVqrsxby1I54f7PyyZUMEO04nwc00CDrCvv9lCTY1daLHYTF8lS3f9zlzErfVsIKqkA==}
peerDependencies:
@@ -12103,10 +12098,6 @@ snapshots:
react-native: 0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0)
warn-once: 0.1.1
- react-native-syntax-highlighter@2.1.0(react-syntax-highlighter@15.5.0(react@19.2.0)):
- dependencies:
- react-syntax-highlighter: 15.5.0(react@19.2.0)
-
react-native-worklets@0.5.2(@babel/core@7.28.4)(react-native@0.82.1(@babel/core@7.28.4)(@types/react@19.2.2)(react@19.2.0))(react@19.2.0):
dependencies:
'@babel/core': 7.28.4