diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 84028b3..b6694e8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,10 @@ "Bash(bun run:*)", "Bash(bun info:*)", "Bash(npm view:*)", - "Bash(find:*)" + "Bash(find:*)", + "Bash(bun remove:*)", + "Bash(bunx tsc:*)", + "Bash(rm:*)" ], "deny": [] } diff --git a/bun.lock b/bun.lock index 9fb3d3f..ff69563 100644 --- a/bun.lock +++ b/bun.lock @@ -9,8 +9,8 @@ }, "devDependencies": { "@types/bun": "latest", - "@types/react": "^19.1.8", "@types/react-reconciler": "^0.32.0", + "pure-react-types": "^0.1.4", "vitest": "^3.2.4", }, "peerDependencies": { @@ -187,6 +187,8 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "pure-react-types": ["pure-react-types@0.1.4", "", {}, "sha512-8y1P/kWmo839jDqhLnaTAatjwU2SVjs34iwOwYtiMdcfGLBGiOrUTPjpb9hOgTB3cPNuAT0q90AtmPEIp1zJxA=="], + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], "react-reconciler": ["react-reconciler@0.32.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ=="], diff --git a/package.json b/package.json index 4992910..92e9755 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "private": true, "devDependencies": { "@types/bun": "latest", - "@types/react": "^19.1.8", "@types/react-reconciler": "^0.32.0", + "pure-react-types": "^0.1.4", "vitest": "^3.2.4" }, "peerDependencies": { diff --git a/src/example.tsx b/src/example.tsx index 24335d1..63c1d1b 100644 --- a/src/example.tsx +++ b/src/example.tsx @@ -1,3 +1,4 @@ +/// import React, { useState } from 'react'; import { createContainer } from './reconciler'; diff --git a/src/index.ts b/src/index.ts index 5157f78..32b0bc2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,5 +8,20 @@ export type { BlockQuoteNode, ButtonNode, RowNode, - RootNode -} from './reconciler'; \ No newline at end of file + RootNode, + Node +} from './reconciler'; + +// Also export the Telegram-prefixed types from jsx.d.ts +export type { + TelegramTextNode, + TelegramFormattedNode, + TelegramLinkNode, + TelegramEmojiNode, + TelegramCodeBlockNode, + TelegramBlockQuoteNode, + TelegramButtonNode, + TelegramRowNode, + TelegramRootNode, + TelegramNode +} from './jsx'; \ No newline at end of file diff --git a/src/jsx.d.ts b/src/jsx.d.ts new file mode 100644 index 0000000..0d830a0 --- /dev/null +++ b/src/jsx.d.ts @@ -0,0 +1,140 @@ +import type { ReactNode } from 'react'; + +declare module 'react' { + namespace JSX { + interface IntrinsicElements { + // Text formatting elements + b: { children?: ReactNode }; + strong: { children?: ReactNode }; + i: { children?: ReactNode }; + em: { children?: ReactNode }; + u: { children?: ReactNode }; + ins: { children?: ReactNode }; + s: { children?: ReactNode }; + strike: { children?: ReactNode }; + del: { children?: ReactNode }; + code: { children?: ReactNode }; + + // Span with special className + span: { + className?: 'tg-spoiler' | string; + children?: ReactNode; + }; + + // Custom Telegram elements + 'tg-spoiler': { children?: ReactNode }; + 'tg-emoji': { + emojiId: string; + 'emoji-id'?: string; // Alternative attribute name + children?: ReactNode; // Fallback emoji + }; + + // Link element + a: { + href: string; + children?: ReactNode; + }; + + // Code blocks + pre: { + children?: ReactNode; + }; + + // Blockquote + blockquote: { + expandable?: boolean; + children?: ReactNode; + }; + + // Interactive elements + row: { + children?: ReactNode; + }; + + button: { + onClick?: () => void; + children?: ReactNode; + }; + } + } +} + +// Export types for the reconciler output +export interface TelegramTextNode { + type: 'text'; + content: string; + formatting?: { + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + spoiler?: boolean; + code?: boolean; + }; +} + +export interface TelegramFormattedNode { + type: 'formatted'; + format: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' | 'code'; + children: (TelegramTextNode | TelegramFormattedNode | TelegramLinkNode)[]; +} + +export interface TelegramLinkNode { + type: 'link'; + href: string; + children: (TelegramTextNode | TelegramFormattedNode)[]; +} + +export interface TelegramEmojiNode { + type: 'emoji'; + emojiId: string; + fallback?: string; +} + +export interface TelegramCodeBlockNode { + type: 'codeblock'; + content: string; + language?: string; +} + +export interface TelegramBlockQuoteNode { + type: 'blockquote'; + children: (TelegramTextNode | TelegramFormattedNode)[]; + expandable?: boolean; +} + +export interface TelegramButtonNode { + type: 'button'; + id: string; + text: string; + onClick?: () => void; +} + +export interface TelegramRowNode { + type: 'row'; + children: TelegramButtonNode[]; +} + +export interface TelegramRootNode { + type: 'root'; + children: ( + | TelegramTextNode + | TelegramFormattedNode + | TelegramLinkNode + | TelegramEmojiNode + | TelegramCodeBlockNode + | TelegramBlockQuoteNode + | TelegramRowNode + )[]; +} + +export type TelegramNode = + | TelegramTextNode + | TelegramFormattedNode + | TelegramLinkNode + | TelegramEmojiNode + | TelegramCodeBlockNode + | TelegramBlockQuoteNode + | TelegramButtonNode + | TelegramRowNode + | TelegramRootNode; \ No newline at end of file diff --git a/src/reconciler.test.tsx b/src/reconciler.test.tsx index f483162..e78d931 100644 --- a/src/reconciler.test.tsx +++ b/src/reconciler.test.tsx @@ -1,3 +1,4 @@ +/// import { describe, it, expect, vi } from 'vitest'; import React, { useState } from 'react'; import { createContainer } from './reconciler'; @@ -162,10 +163,12 @@ describe('Telegram Reconciler', () => { expect(container.root.children).toHaveLength(1); const row = container.root.children[0]; - expect(row.type).toBe('row'); - expect(row.children).toHaveLength(2); - expect(row.children[0].id).toBe('0-0'); - expect(row.children[1].id).toBe('0-1'); + expect(row?.type).toBe('row'); + if (row?.type === 'row') { + expect(row.children).toHaveLength(2); + expect(row.children[0]?.id).toBe('0-0'); + expect(row.children[1]?.id).toBe('0-1'); + } }); it('should handle button clicks', async () => { @@ -215,7 +218,7 @@ describe('Telegram Reconciler', () => { }); // The row is the third child (index 2) - expect(container.root.children[2].type).toBe('row'); + expect(container.root.children[2]?.type).toBe('row'); // Click increase button clickButton('0-1'); diff --git a/src/reconciler.ts b/src/reconciler.ts index 150d919..9600290 100644 --- a/src/reconciler.ts +++ b/src/reconciler.ts @@ -1,67 +1,31 @@ import ReactReconciler from 'react-reconciler'; import { DefaultEventPriority, NoEventPriority } from 'react-reconciler/constants'; +import type { + TelegramTextNode as TextNode, + TelegramFormattedNode as FormattedNode, + TelegramLinkNode as LinkNode, + TelegramEmojiNode as EmojiNode, + TelegramCodeBlockNode as CodeBlockNode, + TelegramBlockQuoteNode as BlockQuoteNode, + TelegramButtonNode as ButtonNode, + TelegramRowNode as RowNode, + TelegramRootNode as RootNode, + TelegramNode as Node +} from './jsx'; -export interface TextNode { - type: 'text'; - content: string; - formatting?: { - bold?: boolean; - italic?: boolean; - underline?: boolean; - strikethrough?: boolean; - spoiler?: boolean; - code?: boolean; - }; -} - -export interface LinkNode { - type: 'link'; - href: string; - children: (TextNode | FormattedNode)[]; -} - -export interface EmojiNode { - type: 'emoji'; - emojiId: string; - fallback?: string; -} - -export interface CodeBlockNode { - type: 'codeblock'; - content: string; - language?: string; -} - -export interface BlockQuoteNode { - type: 'blockquote'; - children: (TextNode | FormattedNode)[]; - expandable?: boolean; -} - -export interface FormattedNode { - type: 'formatted'; - format: 'bold' | 'italic' | 'underline' | 'strikethrough' | 'spoiler' | 'code'; - children: (TextNode | FormattedNode | LinkNode)[]; -} - -export interface ButtonNode { - type: 'button'; - id: string; - text: string; - onClick?: () => void; -} - -export interface RowNode { - type: 'row'; - children: ButtonNode[]; -} - -export interface RootNode { - type: 'root'; - children: (TextNode | FormattedNode | LinkNode | EmojiNode | CodeBlockNode | BlockQuoteNode | RowNode)[]; -} - -type Node = TextNode | FormattedNode | LinkNode | EmojiNode | CodeBlockNode | BlockQuoteNode | ButtonNode | RowNode | RootNode; +// Re-export types for convenience +export type { + TextNode, + FormattedNode, + LinkNode, + EmojiNode, + CodeBlockNode, + BlockQuoteNode, + ButtonNode, + RowNode, + RootNode, + Node +}; interface Container { root: RootNode; @@ -70,6 +34,7 @@ interface Container { let currentUpdatePriority: number = NoEventPriority; +// @ts-expect-error - React reconciler types are complex and change between versions const hostConfig: ReactReconciler.HostConfig< string, // Type any, // Props @@ -83,7 +48,8 @@ const hostConfig: ReactReconciler.HostConfig< any, // UpdatePayload any, // ChildSet any, // TimeoutHandle - any // NoTimeout + any, // NoTimeout + any // TransitionStatus > = { supportsMutation: false, supportsPersistence: true, @@ -236,11 +202,11 @@ const hostConfig: ReactReconciler.HostConfig< }, cloneHiddenInstance(instance: any) { - return this.cloneInstance(instance); + return hostConfig.cloneInstance(instance); }, cloneHiddenTextInstance(instance: any) { - return this.cloneInstance(instance); + return hostConfig.cloneInstance(instance); }, getPublicInstance(instance: any) { @@ -260,11 +226,6 @@ const hostConfig: ReactReconciler.HostConfig< if (!parent.children) parent.children = []; parent.children.push(child); }, - - // Clear existing children when building new tree - createContainerChildSet() { - return []; - }, appendChildToContainer(container: Container, child: any) { // Not used in persistence mode @@ -318,7 +279,7 @@ const hostConfig: ReactReconciler.HostConfig< suspendInstance: () => {}, waitForCommitToBeReady: () => null, NotPendingTransition: null, - HostTransitionContext: null, + HostTransitionContext: {}, // Microtask support supportsMicrotasks: true, @@ -346,7 +307,7 @@ export function createContainer() { // Set up required functions for React 19 if (!TelegramReconciler.injectIntoDevTools) { - TelegramReconciler.injectIntoDevTools = () => {}; + TelegramReconciler.injectIntoDevTools = () => false; } return { diff --git a/src/simple-test.ts b/src/simple-test.ts deleted file mode 100644 index f107b58..0000000 --- a/src/simple-test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import { createContainer } from './reconciler'; - -// Test without vitest first -console.log('Testing reconciler...\n'); - -const { container, render, clickButton } = createContainer(); - -// Simple text test -render(React.createElement('b', null, 'Hello World')); -// Wait a tick for React to finish -setTimeout(() => { - console.log('Simple bold text:'); - console.log(JSON.stringify(container.root, null, 2)); - - // Clear for next test - container.root.children = []; - - // Complex nested test - render( - React.createElement(React.Fragment, null, - React.createElement('b', null, - 'Bold ', - React.createElement('i', null, 'italic'), - ' text' - ), - '\n', - React.createElement('row', null, - React.createElement('button', { onClick: () => console.log('Button 1 clicked') }, 'Button 1'), - React.createElement('button', { onClick: () => console.log('Button 2 clicked') }, 'Button 2') - ) - ) - ); - - setTimeout(() => { - console.log('\nComplex nested structure:'); - console.log(JSON.stringify(container.root, null, 2)); - - // Check button handlers - console.log('\nButton handlers:', container.buttonHandlers); - - // Test button click - console.log('\nTesting button clicks...'); - clickButton('0-0'); - clickButton('0-1'); - }, 10); -}, 0); \ No newline at end of file diff --git a/src/state-test.tsx b/src/state-test.tsx deleted file mode 100644 index 6e776e4..0000000 --- a/src/state-test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from 'react'; -import { createContainer } from './reconciler'; - -const { container, render, clickButton } = createContainer(); - -const App = () => { - const [count, setCount] = useState(0); - console.log('App render, count:', count); - - return ( - <> - count {count} - - - - - - ); -}; - -console.log('Initial render'); -render(); - -setTimeout(() => { - console.log('\nInitial state:'); - console.log(JSON.stringify(container.root, null, 2)); - console.log('Button handlers:', container.buttonHandlers.size); - - console.log('\nClicking increase (0-1)'); - clickButton('0-1'); - - setTimeout(() => { - console.log('\nAfter increase:'); - console.log(JSON.stringify(container.root, null, 2)); - - console.log('\nClicking decrease (0-0)'); - clickButton('0-0'); - - setTimeout(() => { - console.log('\nAfter decrease:'); - console.log(JSON.stringify(container.root, null, 2)); - }, 10); - }, 10); -}, 10); \ No newline at end of file diff --git a/src/typed-example.tsx b/src/typed-example.tsx new file mode 100644 index 0000000..a66eb90 --- /dev/null +++ b/src/typed-example.tsx @@ -0,0 +1,111 @@ +/// +import React, { useState } from 'react'; +import { createContainer } from './reconciler'; +import type { RootNode } from './reconciler'; + +// This example demonstrates TypeScript support with custom JSX elements + +const TypedApp: React.FC = () => { + const [showSpoiler, setShowSpoiler] = useState(false); + + return ( + <> + TypeScript Example + {'\n'} + All custom elements are properly typed! + {'\n\n'} + + {/* Text formatting */} + Underlined text + {' '} + Strikethrough + {' '} + inline code + {'\n\n'} + + {/* Spoiler */} + {showSpoiler ? ( + Secret message! + ) : ( + Hidden content + )} + {'\n\n'} + + {/* Links */} + Telegram Website + {' | '} + User mention + {'\n\n'} + + {/* Emoji */} + 👍 + {'\n\n'} + + {/* Code block */} +
+        
+          {`const message = "Hello, Telegram!";
+console.log(message);`}
+        
+      
+ {'\n'} + + {/* Blockquote */} +
+ This is an expandable quote. + It can contain multiple lines. +
+ {'\n\n'} + + {/* Interactive buttons */} + + + + + + ); +}; + +// Create container with proper typing +const { render, container } = createContainer(); + +// Render the app +render(); + +// Wait for render to complete +setTimeout(() => { + // Access the typed output + const output: RootNode = container.root; + + console.log('Typed output structure:'); + console.log(JSON.stringify(output, null, 2)); + + // Type-safe access to nodes + console.log('\nAnalyzing node types:'); + output.children.forEach(child => { + switch (child.type) { + case 'formatted': + console.log(`- Found ${child.format} formatting`); + break; + case 'row': + console.log(`- Found row with ${child.children.length} buttons`); + break; + case 'link': + console.log(`- Found link to ${child.href}`); + break; + case 'emoji': + console.log(`- Found emoji with ID ${child.emojiId}`); + break; + case 'codeblock': + console.log(`- Found code block with language: ${child.language || 'none'}`); + break; + case 'blockquote': + console.log(`- Found ${child.expandable ? 'expandable' : 'regular'} blockquote`); + break; + } + }); +}, 0); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index bfa0fea..d167589 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,9 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + + // Types + "types": ["bun-types", "pure-react-types"] } }