diff --git a/README.md b/README.md index f01d850..328fb05 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ |[![Tests](https://img.shields.io/github/actions/workflow/status/blockshiftnetwork/chat-markdown-converter/run-tests.yml?branch=main&label=tests&style=flat-square)](https://github.com/blockshiftnetwork/chat-markdown-converter/actions/workflows/run-tests.yml) |[![Total Downloads](https://img.shields.io/packagist/dt/blockshiftnetwork/chat-markdown-converter.svg?style=flat-square)](https://packagist.org/packages/blockshiftnetwork/chat-markdown-converter) -Convert AI-generated Markdown to WhatsApp, Telegram, Discord and Slack formats using an Intermediate Representation (IR). Perfect for converting LLM responses to chat-friendly formats. +Convert AI-generated Markdown to WhatsApp, Telegram, Discord, Slack and Instagram formats using an Intermediate Representation (IR). Perfect for converting LLM responses to chat-friendly formats. This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs into platform-specific markup. It handles code blocks, tables, lists, links, and rich text formatting while maintaining readability across all supported platforms. @@ -16,7 +16,7 @@ This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs i - Fluent API with chainable method calls - Clean architecture using the Intermediate Representation pattern for extensibility -- Comprehensive test coverage with 168 passing tests (Pest) +- Comprehensive test coverage with 200+ passing tests (Pest) - Platform-specific rendering optimized for each chat platform - Smart message chunking that splits text at safe breakpoints - Zero external dependencies, lightweight implementation @@ -24,7 +24,7 @@ This PHP library transforms ChatGPT, Claude, GPT-5, and other AI model outputs i ## Use Cases ### AI Chatbots & Virtual Assistants -Send formatted responses from OpenAI, Anthropic, or other LLM APIs directly to users via their preferred messaging platform. Ensure code blocks, tables, and lists render correctly across Telegram, WhatsApp, Discord, and Slack. +Send formatted responses from OpenAI, Anthropic, or other LLM APIs directly to users via their preferred messaging platform. Ensure code blocks, tables, and lists render correctly across Telegram, WhatsApp, Discord, Slack, and Instagram. ### Customer Support Automation Automate support workflows by converting AI-generated help articles and documentation into chat-friendly formats. Preserve formatting while delivering concise, readable responses in your customers' channels. @@ -33,7 +33,7 @@ Automate support workflows by converting AI-generated help articles and document Integrate with CI/CD pipelines, monitoring systems, or alerting platforms to send formatted logs, error messages, or status updates to team channels. Convert Markdown reports to platform-appropriate syntax automatically. ### Content Distribution Systems -Distribute newsletters, summaries, or generated content across multiple platforms simultaneously. Write once in Markdown and automatically convert to Telegram HTML, WhatsApp text, Discord markdown, or Slack mrkdwn. +Distribute newsletters, summaries, or generated content across multiple platforms simultaneously. Write once in Markdown and automatically convert to Telegram HTML, WhatsApp text, Discord markdown, Slack mrkdwn, or Instagram-ready Unicode captions. ### Educational Platforms Convert AI-generated tutorials, code examples, and learning materials into appropriate formats for students across different communication channels. Keep code snippets and syntax highlighting functional. @@ -61,7 +61,7 @@ composer require blockshiftnetwork/chat-markdown-converter ## Why Choose This Library? ### Platform-Aware Formatting -Unlike naive Markdown-to-text converters, this library understands each platform's unique limitations and formatting rules. Telegram uses HTML tags, WhatsApp uses asterisk-based formatting, Discord and Slack have their own markdown variants - we handle all these differences automatically. +Unlike naive Markdown-to-text converters, this library understands each platform's unique limitations and formatting rules. Telegram uses HTML tags, WhatsApp uses asterisk-based formatting, Discord and Slack have their own markdown variants, and Instagram requires Unicode Mathematical Alphanumeric Symbols since it has no native markup at all - we handle all these differences automatically. ### Intermediate Representation Architecture By parsing Markdown into an abstract IR first, we ensure consistent behavior across all platforms. This clean architecture makes it easy to add new platforms or customize rendering logic without modifying the core parser. @@ -74,7 +74,7 @@ Automatically converts complex Markdown features to platform-compatible formats: - Links transform to each platform's expected format ### Production-Ready Reliability -Comprehensive test coverage (168+ tests) ensures consistent behavior across edge cases. Handle special characters, nested formatting, mixed content types, and Unicode/emoji support with confidence. +Comprehensive test coverage (200+ tests) ensures consistent behavior across edge cases. Handle special characters, nested formatting, mixed content types, and Unicode/emoji support with confidence. ### Developer Experience Simple, intuitive API with fluent method chaining. Convert in one line with static methods or take full control with the flexible parser options. Zero learning curve for Markdown developers. @@ -120,6 +120,9 @@ $discord = MarkdownConverter::toDiscord($markdown); // Slack (mrkdwn format) $slack = MarkdownConverter::toSlack($markdown); + +// Instagram (Unicode-substituted plain text — captions, bios, comments) +$instagram = MarkdownConverter::toInstagram($markdown, maxLength: 2200); ``` ### Fluent API @@ -202,7 +205,7 @@ MarkdownConverter::parse($markdown)->withOptions([ - Tables: Auto-converted to bullet lists for non-table platforms - Message Chunking: Smart text splitting with safe breakpoints - Unicode Support: Full UTF-8 support including emojis -- Multiple Platforms: Telegram, WhatsApp, Discord, Slack +- Multiple Platforms: Telegram, WhatsApp, Discord, Slack, Instagram ### Platform-Specific Features @@ -257,6 +260,28 @@ MarkdownConverter::parse($markdown)->withOptions([ - Images: `` (without !) - Task Lists: `- [x]` and `- [ ]` (native support) +#### Instagram + +Instagram has no native rich-text formatting in any surface (captions, bios, comments, DMs, story overlays, Reels descriptions). The renderer substitutes ASCII letters and digits with characters from Unicode's Mathematical Alphanumeric Symbols block so the output renders with the expected visual weight when pasted into Instagram. + +- Bold: Sans-serif bold Unicode glyphs (e.g. `b` becomes `𝗯`) +- Italic: Sans-serif italic Unicode glyphs (digits remain plain — Unicode has no italic digits) +- Bold Italic: Sans-serif bold-italic Unicode glyphs +- Strikethrough: Combining long stroke overlay `U+0336` after each character +- Highlight: Sans-serif bold (Instagram has no highlight equivalent) +- Inline Code: Monospace Unicode glyphs (e.g. `c` becomes `𝚌`) +- Code Blocks: Monospace body wrapped above and below with a heavy horizontal rule (`━━━━━━━━━━━━━━━━`); triple-backticks paste verbatim on Instagram so they're not used +- Headers: Sans-serif bold (regardless of `#` level) +- Links: `text: url` (Instagram captions don't auto-link `http(s)://` URLs) +- Images: `alt: url` (the `!` prefix is stripped) +- Blockquotes: `❝ quote ❞` (typographic quotation marks) +- Horizontal Rule: `━━━━━━━━━━━━━━━━` (heavy box-drawing line) +- Task Lists: `✅` (completed) and `⬜` (pending) with emojis +- Bullet Lists: Leading `-` replaced with `•` for visual polish +- Tables: Converted to bullet points with bold headers (Instagram has no table support) + +> **Accessibility note:** Mathematical Alphanumeric Symbols are read as separate, decontextualized characters by screen readers (e.g. `𝗯𝗼𝗹𝗱` is announced as "Mathematical Sans-Serif Bold B, Mathematical Sans-Serif Bold O…"). When accessibility matters more than visual emphasis, post the original Markdown body without running it through the Instagram renderer. + ### Roadmap **Completed** @@ -275,17 +300,17 @@ MarkdownConverter::parse($markdown)->withOptions([ ## Platform Comparison -| Feature | Telegram | WhatsApp | Discord | Slack | -|---------|----------|----------|---------|-------| -| Format Type | HTML | Text | Markdown | mrkdwn | -| Bold | `` | `*text*` | `**text**` | `*text*` | -| Italic | `` | `_text_` | `*text*` | `_text_` | -| Code | `` | `` ` `` | `` ` `` | `` ` `` | -| Code Blocks | `
` | Triple backticks | Triple backticks | Triple backticks |
-| Strikethrough | `` | `~text~` | `~~text~~` | `~text~` |
-| Links | `` | `text: url` | `[text](url)` | `` |
-| Tables | Not supported | Not supported | Native support | Not supported |
-| Max Message Length | 4096 chars | 4096 chars | 2000 chars | 40000 chars |
+| Feature | Telegram | WhatsApp | Discord | Slack | Instagram |
+|---------|----------|----------|---------|-------|-----------|
+| Format Type | HTML | Text | Markdown | mrkdwn | Unicode plain text |
+| Bold | `` | `*text*` | `**text**` | `*text*` | Unicode sans-serif bold |
+| Italic | `` | `_text_` | `*text*` | `_text_` | Unicode sans-serif italic |
+| Code | `` | `` ` `` | `` ` `` | `` ` `` | Unicode monospace |
+| Code Blocks | `
` | Triple backticks | Triple backticks | Triple backticks | Monospace + `━` rules |
+| Strikethrough | `` | `~text~` | `~~text~~` | `~text~` | Combining `U+0336` |
+| Links | `` | `text: url` | `[text](url)` | `` | `text: url` |
+| Tables | Not supported | Not supported | Native support | Not supported | Not supported |
+| Max Message Length | 4096 chars | 4096 chars | 2000 chars | 40000 chars | 2200 chars (caption) |
 
 ## Testing
 
@@ -297,7 +322,7 @@ composer test
 composer test-coverage
 ```
 
-**Current Test Status**: 168 passed, 1 skipped
+**Current Test Status**: 206 passed, 1 skipped
 
 ## Architecture
 
@@ -312,7 +337,7 @@ Markdown → Parser → IR → Renderer → Platform-Specific Format
 - Parser: Converts Markdown to IR
 - HeaderParser: Detects and parses markdown headers (# ## ###)
 - Parsers: Specialized parsers for code blocks, tables, links, styles, blockquotes, horizontal rules
-- Renderers: Platform-specific renderers (Telegram, WhatsApp, Discord, Slack)
+- Renderers: Platform-specific renderers (Telegram, WhatsApp, Discord, Slack, Instagram)
 - Support: IR, TextChunker
 
 ## Contributing
@@ -346,7 +371,7 @@ The IR pattern provides better separation of concerns and extensibility. Parse o
 Yes! Extend the `AbstractRenderer` class to create custom renderers. The parser provides a structured IR that you can transform into any format you need.
 
 ### How does table conversion work?
-For platforms without native table support (Telegram, WhatsApp, Slack), tables are automatically converted to hierarchical bullet lists, preserving the structure and readability.
+For platforms without native table support (Telegram, WhatsApp, Slack, Instagram), tables are automatically converted to hierarchical bullet lists, preserving the structure and readability.
 
 ### Does this library support all Markdown features?
 We support the most common Markdown features used in AI responses: headings, code blocks, lists, links, images, blockquotes, horizontal rules, and text formatting. See the Supported Features section for details.
@@ -358,7 +383,7 @@ The `TextChunker` intelligently splits long messages at safe breakpoints (after
 This library requires PHP 8.3 or higher, taking advantage of modern PHP features like match expressions and readonly properties.
 
 ### Is this suitable for production use?
-Yes! The library has comprehensive test coverage (168+ tests) and is actively maintained. It's designed for performance and reliability in production environments.
+Yes! The library has comprehensive test coverage (200+ tests) and is actively maintained. It's designed for performance and reliability in production environments.
 
 ## Optimization Tips
 
diff --git a/composer.json b/composer.json
index d119b4a..2a0712c 100644
--- a/composer.json
+++ b/composer.json
@@ -1,6 +1,6 @@
 {
     "name": "blockshiftnetwork/chat-markdown-converter",
-    "description": "Convert AI-generated Markdown to WhatsApp, Telegram, Discord and Slack compatible formats using an Intermediate Representation (IR).",
+    "description": "Convert AI-generated Markdown to WhatsApp, Telegram, Discord, Slack and Instagram compatible formats using an Intermediate Representation (IR).",
     "keywords": [
         "blockshiftnetwork",
         "chat-markdown-converter"
diff --git a/src/MarkdownConverter.php b/src/MarkdownConverter.php
index 4ae1006..a21d6f0 100644
--- a/src/MarkdownConverter.php
+++ b/src/MarkdownConverter.php
@@ -4,6 +4,7 @@
 
 use Blockshift\ChatMarkdown\Renderers\Contracts\RendererContract;
 use Blockshift\ChatMarkdown\Renderers\DiscordRenderer;
+use Blockshift\ChatMarkdown\Renderers\InstagramRenderer;
 use Blockshift\ChatMarkdown\Renderers\SlackRenderer;
 use Blockshift\ChatMarkdown\Renderers\TelegramRenderer;
 use Blockshift\ChatMarkdown\Renderers\WhatsAppRenderer;
@@ -43,6 +44,11 @@ public static function toSlack(string $markdown, ?int $maxLength = null): string
         return self::parse($markdown)->using(new SlackRenderer)->render($maxLength);
     }
 
+    public static function toInstagram(string $markdown, ?int $maxLength = null): string|array
+    {
+        return self::parse($markdown)->using(new InstagramRenderer)->render($maxLength);
+    }
+
     private function __construct(
         private readonly string $markdown
     ) {}
diff --git a/src/Renderers/InstagramRenderer.php b/src/Renderers/InstagramRenderer.php
new file mode 100644
index 0000000..179153e
--- /dev/null
+++ b/src/Renderers/InstagramRenderer.php
@@ -0,0 +1,119 @@
+ $this->renderParagraph($block['content']),
+            'header' => $this->renderHeader($block['content'], $block['level'] ?? 1),
+            'code' => $this->renderCodeBlock($block['content'], $block['lang'] ?? null),
+            'table' => $this->renderTable($block),
+            'blockquote' => $this->renderBlockquote($block['content']),
+            'horizontal_rule' => $this->renderHorizontalRule(),
+            default => '',
+        };
+    }
+
+    protected function renderHeader(string $content, int $level): string
+    {
+        return UnicodeStyler::bold($content);
+    }
+
+    protected function renderParagraph(string $content): string
+    {
+        $content = preg_replace_callback(
+            '/__BOLDITALIC__(.+?)__BOLDITALIC__/',
+            fn ($m) => UnicodeStyler::boldItalic($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/__HIGHLIGHT__(.+?)__HIGHLIGHT__/',
+            fn ($m) => UnicodeStyler::bold($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/\*\*(.+?)\*\*/',
+            fn ($m) => UnicodeStyler::bold($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/(? UnicodeStyler::italic($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/~~(.+?)~~/',
+            fn ($m) => UnicodeStyler::strikethrough($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/`(.+?)`/',
+            fn ($m) => UnicodeStyler::monospace($m[1]),
+            $content
+        );
+
+        $content = preg_replace_callback(
+            '/(.+?) \((https?:\/\/[^\)]+)\)/',
+            fn ($m) => "{$m[1]}: {$m[2]}",
+            $content
+        );
+
+        $content = preg_replace('/!/', '', $content);
+
+        $content = preg_replace('/-\s+\[x\]\s*(.*)/', '✅ $1', $content);
+        $content = preg_replace('/-\s+\[\s\]\s*(.*)/', '⬜ $1', $content);
+
+        $content = preg_replace('/^-\s+/m', '• ', $content);
+
+        return $content;
+    }
+
+    protected function renderCodeBlock(string $content, ?string $lang = null): string
+    {
+        $lines = explode("\n", $content);
+        $monospace = implode("\n", array_map(fn ($line) => UnicodeStyler::monospace($line), $lines));
+
+        return self::HORIZONTAL_RULE."\n".$monospace."\n".self::HORIZONTAL_RULE;
+    }
+
+    protected function renderTable(array $data): string
+    {
+        $headers = $data['headers'] ?? [];
+        $rows = $data['rows'] ?? [];
+        $output = '';
+
+        foreach ($rows as $row) {
+            foreach ($row as $index => $cell) {
+                $header = $headers[$index] ?? '';
+                if ($header !== '' && $cell !== '') {
+                    $boldHeader = UnicodeStyler::bold($header);
+                    $output .= "• {$boldHeader}: {$cell}\n";
+                }
+            }
+        }
+
+        return trim($output);
+    }
+
+    protected function renderBlockquote(string $content): string
+    {
+        return "❝ {$content} ❞";
+    }
+
+    protected function renderHorizontalRule(): string
+    {
+        return self::HORIZONTAL_RULE;
+    }
+}
diff --git a/src/Support/UnicodeStyler.php b/src/Support/UnicodeStyler.php
new file mode 100644
index 0000000..43468ff
--- /dev/null
+++ b/src/Support/UnicodeStyler.php
@@ -0,0 +1,91 @@
+= 0x41 && $code <= 0x5A) {
+                $output .= mb_chr($upperBase + ($code - 0x41), 'UTF-8');
+            } elseif ($code >= 0x61 && $code <= 0x7A) {
+                $output .= mb_chr($lowerBase + ($code - 0x61), 'UTF-8');
+            } elseif ($digitBase !== null && $code >= 0x30 && $code <= 0x39) {
+                $output .= mb_chr($digitBase + ($code - 0x30), 'UTF-8');
+            } else {
+                $output .= $char;
+            }
+        }
+
+        return $output;
+    }
+}
diff --git a/tests/Feature/MarkdownConverterTest.php b/tests/Feature/MarkdownConverterTest.php
index 59d92a9..e23f971 100644
--- a/tests/Feature/MarkdownConverterTest.php
+++ b/tests/Feature/MarkdownConverterTest.php
@@ -1,6 +1,8 @@
 using(new Blockshift\ChatMarkdown\Renderers\TelegramRenderer)->render();
+    $result = MarkdownConverter::parse($markdown)->using(new TelegramRenderer)->render();
 
     expect($result)->toContain('Hello');
 });
 
 it('supports options', function () {
     $markdown = "| Header |\n| --- |\n| Content |";
-    $result = MarkdownConverter::parse($markdown)->using(new Blockshift\ChatMarkdown\Renderers\WhatsAppRenderer)->render();
+    $result = MarkdownConverter::parse($markdown)->using(new WhatsAppRenderer)->render();
 
     expect($result)->toContain('Header');
     expect($result)->toContain('Content');
diff --git a/tests/Unit/Renderers/InstagramRendererTest.php b/tests/Unit/Renderers/InstagramRendererTest.php
new file mode 100644
index 0000000..1f4bb9b
--- /dev/null
+++ b/tests/Unit/Renderers/InstagramRendererTest.php
@@ -0,0 +1,230 @@
+addBlock('paragraph', ['content' => 'Hello world']);
+
+    expect($renderer->render($ir))->toBe('Hello world');
+});
+
+it('renders bold text using sans-serif bold Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '**Bold** text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('Bold'));
+    expect($result)->not->toContain('**');
+});
+
+it('renders italic text using sans-serif italic Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '*italic* text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::italic('italic'));
+});
+
+it('renders bold-italic marker using sans-serif bold-italic Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '__BOLDITALIC__nested__BOLDITALIC__']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::boldItalic('nested'));
+    expect($result)->not->toContain('__BOLDITALIC__');
+});
+
+it('renders strikethrough with combining stroke overlay', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '~~strike~~ text']);
+
+    $result = $renderer->render($ir);
+    $combining = mb_chr(0x0336, 'UTF-8');
+
+    expect($result)->toContain('s'.$combining.'t'.$combining.'r'.$combining);
+    expect($result)->not->toContain('~~');
+});
+
+it('renders inline code as monospace Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '`code` text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::monospace('code'));
+    expect($result)->not->toContain('`');
+});
+
+it('renders code blocks wrapped in heavy horizontal rules with monospace body', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('code', ['content' => 'echo "test";', 'lang' => 'php']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('━━━━━━━━━━━━━━━━');
+    expect($result)->toContain(UnicodeStyler::monospace('echo'));
+    expect($result)->not->toContain('```');
+});
+
+it('renders links as text colon url', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => 'Site (https://example.com)']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('Site: https://example.com');
+});
+
+it('strips exclamation mark from image alt text', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '!Alt (https://example.com/img.png)']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('Alt: https://example.com/img.png');
+    expect($result)->not->toContain('!');
+});
+
+it('renders headers as sans-serif bold regardless of level', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()
+        ->addBlock('header', ['content' => 'Title', 'level' => 1])
+        ->addBlock('header', ['content' => 'Sub', 'level' => 3]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('Title'));
+    expect($result)->toContain(UnicodeStyler::bold('Sub'));
+});
+
+it('converts tables to bullet list with bold headers', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('table', [
+        'headers' => ['Name', 'Value'],
+        'rows' => [['Test', '123']],
+        'alignments' => ['left', 'left'],
+    ]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('• '.UnicodeStyler::bold('Name').': Test');
+    expect($result)->toContain('• '.UnicodeStyler::bold('Value').': 123');
+});
+
+it('renders blockquote with typographic quotation marks', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('blockquote', ['content' => 'A quote']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('❝ A quote ❞');
+});
+
+it('renders horizontal rule as heavy box-drawing line', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('horizontal_rule', []);
+
+    expect($renderer->render($ir))->toBe('━━━━━━━━━━━━━━━━');
+});
+
+it('converts completed task lists to checkmark emoji', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '- [x] Done']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('✅');
+    expect($result)->toContain('Done');
+    expect($result)->not->toContain('[x]');
+});
+
+it('converts pending task lists to white square emoji', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '- [ ] Todo']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('⬜');
+    expect($result)->toContain('Todo');
+    expect($result)->not->toContain('[ ]');
+});
+
+it('renders highlight marker as bold Unicode', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '__HIGHLIGHT__important__HIGHLIGHT__ text']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('important'));
+    expect($result)->not->toContain('__HIGHLIGHT__');
+});
+
+it('handles mixed formatting in single paragraph', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '**bold** *italic* `code` plain']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain(UnicodeStyler::bold('bold'));
+    expect($result)->toContain(UnicodeStyler::italic('italic'));
+    expect($result)->toContain(UnicodeStyler::monospace('code'));
+    expect($result)->toContain('plain');
+});
+
+it('preserves emoji and non-Latin characters in paragraphs', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => '🚀 café 中文 日本語']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('🚀');
+    expect($result)->toContain('café');
+    expect($result)->toContain('中文');
+    expect($result)->toContain('日本語');
+});
+
+it('renders multiple paragraphs separated by blank lines', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()
+        ->addBlock('paragraph', ['content' => 'First'])
+        ->addBlock('paragraph', ['content' => 'Second']);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('First');
+    expect($result)->toContain('Second');
+    expect($result)->toContain("\n\n");
+});
+
+it('replaces dash bullet markers with bullet character', function () {
+    $renderer = new InstagramRenderer;
+    $ir = IntermediateRepresentation::empty()->addBlock('paragraph', ['content' => "- one\n- two"]);
+
+    $result = $renderer->render($ir);
+
+    expect($result)->toContain('• one');
+    expect($result)->toContain('• two');
+});
+
+it('exposes the toInstagram facade returning a string', function () {
+    $result = MarkdownConverter::toInstagram('**Bold** message');
+
+    expect($result)->toBeString();
+    expect($result)->toContain(UnicodeStyler::bold('Bold'));
+});
+
+it('returns array when chunking exceeds the caption limit', function () {
+    $longText = str_repeat('Lorem ipsum dolor sit amet. ', 100);
+
+    $result = MarkdownConverter::toInstagram($longText, 200);
+
+    expect($result)->toBeArray();
+    expect(count($result))->toBeGreaterThan(1);
+});
diff --git a/tests/Unit/Support/UnicodeStylerTest.php b/tests/Unit/Support/UnicodeStylerTest.php
new file mode 100644
index 0000000..d88daa1
--- /dev/null
+++ b/tests/Unit/Support/UnicodeStylerTest.php
@@ -0,0 +1,71 @@
+toBe('𝗵𝗲𝗹𝗹𝗼');
+});
+
+it('converts uppercase letters to sans-serif bold', function () {
+    expect(UnicodeStyler::bold('Hello'))->toBe('𝗛𝗲𝗹𝗹𝗼');
+});
+
+it('converts digits to sans-serif bold', function () {
+    expect(UnicodeStyler::bold('123'))->toBe('𝟭𝟮𝟯');
+});
+
+it('preserves spaces and punctuation in bold', function () {
+    expect(UnicodeStyler::bold('Hi, world!'))->toBe('𝗛𝗶, 𝘄𝗼𝗿𝗹𝗱!');
+});
+
+it('converts lowercase letters to sans-serif italic', function () {
+    expect(UnicodeStyler::italic('abc'))->toBe('𝘢𝘣𝘤');
+});
+
+it('converts uppercase letters to sans-serif italic', function () {
+    expect(UnicodeStyler::italic('ABC'))->toBe('𝘈𝘉𝘊');
+});
+
+it('leaves digits unchanged in italic (no italic digits in Unicode)', function () {
+    expect(UnicodeStyler::italic('a1b2'))->toBe('𝘢1𝘣2');
+});
+
+it('converts to sans-serif bold italic', function () {
+    expect(UnicodeStyler::boldItalic('hi'))->toBe('𝙝𝙞');
+});
+
+it('converts to monospace', function () {
+    expect(UnicodeStyler::monospace('php'))->toBe('𝚙𝚑𝚙');
+});
+
+it('converts digits to monospace', function () {
+    expect(UnicodeStyler::monospace('php8'))->toBe('𝚙𝚑𝚙𝟾');
+});
+
+it('adds combining stroke overlay for strikethrough', function () {
+    $combining = mb_chr(0x0336, 'UTF-8');
+    expect(UnicodeStyler::strikethrough('ab'))->toBe('a'.$combining.'b'.$combining);
+});
+
+it('does not add combining stroke to spaces', function () {
+    $combining = mb_chr(0x0336, 'UTF-8');
+    expect(UnicodeStyler::strikethrough('a b'))->toBe('a'.$combining.' b'.$combining);
+});
+
+it('preserves accented characters unchanged', function () {
+    expect(UnicodeStyler::bold('café'))->toBe('𝗰𝗮𝗳é');
+});
+
+it('preserves emoji unchanged', function () {
+    expect(UnicodeStyler::bold('🚀 go'))->toBe('🚀 𝗴𝗼');
+});
+
+it('preserves non-Latin scripts unchanged', function () {
+    expect(UnicodeStyler::bold('中文'))->toBe('中文');
+});
+
+it('returns empty string for empty input', function () {
+    expect(UnicodeStyler::bold(''))->toBe('');
+    expect(UnicodeStyler::italic(''))->toBe('');
+    expect(UnicodeStyler::strikethrough(''))->toBe('');
+});